Architecture
Lokvis follows a strict five-layer architecture with one-way dependencies. See docs/business/04-技术架构设计.md for the full spec.
Layers
Section titled “Layers”1. UI Layer
Section titled “1. UI Layer”Astro 7 + React 19 + Tailwind v4. Renders the Workspace UI and tool pages.
2. Workflow Layer
Section titled “2. Workflow Layer”Linear workflow executor (Year 1 only). Each workflow is a chain of transform nodes connected by edges. No branches, loops, conditions, or parallelism yet.
3. Runtime Layer
Section titled “3. Runtime Layer”The “browser operating system”. Single responsibility: Input → Run → Output. Manages assets, capabilities, history, and event bus. Never knows about React.
4. Capability Layer
Section titled “4. Capability Layer”<domain>.<action> naming convention (e.g. image.resize, video.transcode). Runtime never knows about FFmpeg — only about capabilities.
5. Engine Layer
Section titled “5. Engine Layer”Pluggable adapters wrap WASM libraries (Squoosh, ffmpeg.wasm, pdf-lib). The MVP image engine uses native browser Canvas + createImageBitmap for zero-dependency fastest first paint.
Four-layer collaboration
Section titled “Four-layer collaboration”The middle four layers (Runtime / Capability / Plugin / Engine) form the execution core. Each has a single, non-overlapping job; together they turn a declarative Workflow into bytes on disk.
Request flow: runtime.run(workflow, inputs) → bytes
Section titled “Request flow: runtime.run(workflow, inputs) → bytes”-
Runtime (
@lokvis/runtime) — topological-sorts the workflow nodes, resolves each node’s capability against theCapabilityRegistry, and drives theWorkflowExecutor. It only ever holdsAssetIds; rawBlobs live in theAssetStore(OPFS → IDB → memory fallback). -
Capability (
@lokvis/capability+@lokvis/schema) —CapabilityRegistry.resolve(name)returns the bestCapabilityImplementationfor a capability name, skippingstubimplementations automatically. TheCapabilitytype itself is a pure declarative contract (name/inputTypes/outputTypes/paramsschema) owned by Schema, so any layer can depend on it without pulling in Runtime. -
Plugin (
@lokvis/plugin-*) — theCapabilityImplementation.executethat the registry returns is built by a plugin viacreateBlobCapabilityImpl(). The plugin’soperation(blob, params, signal)is where Asset ↔ Blob conversion happens: it pulls aBlobfrom the AssetStore, hands it to an Engine function, and creates a new Asset from the result. Plugins are the only layer that knows about both Assets (Capability vocabulary) and Blobs (Engine vocabulary). -
Engine (
@lokvis/engine-*) — a pureBlob → Blobfunction (resize,compress,convert, …). It knows nothing about Assets, capabilities, or workflows. This isolation is what lets the same engine power a browser Workspace, a Node CLI, and an MCP server without change.
Why one-way dependencies matter
Section titled “Why one-way dependencies matter”The arrow Runtime → Capability → Engine (with Plugin bridging Capability and Engine) is enforced at the package level via package.json dependencies. Concretely:
@lokvis/runtimenever imports@lokvis/engine-image— it only resolves capability names and callsimpl.execute().@lokvis/engine-imagenever imports@lokvis/runtimeor@lokvis/schema— it is a set of pure functions.- A plugin (
@lokvis/plugin-image) imports both its engine and@lokvis/capabilityto glue them together, but Runtime never imports the plugin directly; plugins are registered at runtime viainstallPlugin().
This means you can swap the Canvas engine for a WASM engine (or a sharp engine in Node) by shipping a different plugin — Runtime, Capability, and Workflow code stay unchanged.
Deep dives
Section titled “Deep dives”Each layer has its own deep-dive document:
- Runtime — AssetStore three-tier fallback, CapabilityRegistry resolve flow, WorkflowExecutor linear DAG, HistoryStack cursor, MemoryGuard, Worker isolation, extension hooks.
- Engine —
IMAGE_ENGINEdescriptor + standalonedecodeImage/encodeImageprimitives,Record<string, any>operation signature rule, Canvas implementation +decodeResized, streaming type contracts, Worker integration, stub engine pattern. - Capability —
Capabilityshape, 8CapabilityParamtypes, capability catalog (image real / video-pdf stub),CapabilityRegistry.resolvethree-stage flow, three-layer workflow validation, MCP manifest generation. - Plugin SDK —
definePlugin+createBlobCapabilityImplfactories,PluginContextrestricted Runtime view, lifecycle, MetadataReader, stub plugin pattern, Panels, Vitest testing pattern.
Dependency Rules
Section titled “Dependency Rules”lokvis-opennever imports fromlokvis-cloud- Runtime never knows about React / Redux
- Schema never knows about Runtime
- Plugin never knows about Cloud
Storage
Section titled “Storage”Metadata in IndexedDB (via Dexie in future), large files in OPFS (Origin Private File System). Runtime only manipulates AssetIds — never raw Blobs.
Worker Isolation
Section titled “Worker Isolation”All heavy computation (image encoding/decoding, future WASM engines) runs inside a dedicated Web Worker, decoupled from the main-thread UI to avoid long tasks blocking rendering (whitepaper T1 “Browser memory/performance ★★★★★”). Cross-origin isolation (COOP/COEP) is configured in apps/playground/public/_headers, enabling SharedArrayBuffer for multi-threaded WASM.
Communication Protocol (@lokvis/runtime worker-protocol)
Section titled “Communication Protocol (@lokvis/runtime worker-protocol)”Pure JSON messages, split into two groups by direction:
- Host → Worker:
request(method call with id),ping(heartbeat) - Worker → Host:
response(correlated by id),pong,event(progress, etc.),ready(handshake),error(fatal error)
After startup the Worker proactively sends ready (carrying the protocol version); the Host validates the version before sending any requests.
Protocol interaction sequence (ready handshake → heartbeat → request/response → crash-restart):
Host Management (WorkerHost)
Section titled “Host Management (WorkerHost)”- Request/Response: Each request has a unique id; responses are correlated by id; each request has an independent timeout (default 60s).
- Heartbeat & timeout: The Host periodically sends
ping(default 5s); the Worker must reply withpongwithin the timeout (default 15s) or it is considered crashed. - Crash restart: Transport-layer error / heartbeat timeout → terminate the current Worker, reject all pending requests, rebuild a new Worker (up to
maxRestartstimes, default 3). When the limit is exceeded it entersdeadstate and the upper layer handles degradation. - Transport abstraction: The
WorkerTransportinterface shields the differences between browser Workers and test fakes, so the protocol and restart logic can be verified in Node unit tests.
WorkerHost state machine (idle → ready → restarting → dead/disposed):
Engine in Worker (@lokvis/engine-image worker-adapter)
Section titled “Engine in Worker (@lokvis/engine-image worker-adapter)”Image operations are rendered inside the Worker using OffscreenCanvas + createImageBitmap. The Worker entry calls startImageWorker() to wire up the ready handshake, ping→pong, and request→response; method names align with capability names (image.resize / image.compress / …). This module depends only on @lokvis/schema; its message structure is compatible with the runtime protocol and it does not depend back on runtime.
Engine Image Worker wiring flow (inside the Worker thread):
History Stack and undo/redo (HistoryStack)
Section titled “History Stack and undo/redo (HistoryStack)”Each workflow owns an independent HistoryStack that records a HistoryEntry for every transform step. undo/redo is implemented via a cursor: the cursor points to the last applied entry; appending a new operation truncates any redo branches after the cursor; when the limit (default 10 steps) is exceeded, LRU evicts the oldest entry and invokes onEvict to notify the caller to clean up the OPFS asset. Every change emits a history:changed event through the eventBus.
HistoryStack internal structure:
append / undo / redo and redo-branch truncation flow:
Asset Storage and Three-Tier Fallback (AssetStore)
Section titled “Asset Storage and Three-Tier Fallback (AssetStore)”The Runtime only manipulates AssetIds and never holds Blobs directly. Asset storage is provided by the createAssetStore() factory, which auto-detects the best backend by priority: OPFS (best, supports FileSystemSyncAccessHandle) → IndexedDB (Dexie, persistent fallback) → memory (final fallback, non-persistent). Each backend is distinguished by a BlobHandle.path prefix (opfs:// / idb:// / memory://). The Runtime runs checkStorageQuota() before run() and throws QuotaExceededError when the limit is exceeded.
createAssetStore three-tier fallback detection chain:
Data layout for the OPFS / IDB / memory backends:
Runtime run() with quota validation and history wiring flow:
undo/redo end-to-end flow (including cursor movement and event emission):
Streaming and Memory Defense (W3: Streaming + Memory Defense)
Section titled “Streaming and Memory Defense (W3: Streaming + Memory Defense)”A single browser tab has limited memory (typically 2–4 GB on desktop, less on mobile), so batch processing of large images can easily trigger OOM crashes (the entire tab is killed, losing all intermediate results). W3 refines the all-or-nothing decision into an observable, degradable memory-safety pipeline through four layers of mechanisms.
Capability catalog landed in the Alpha phase (W12): 9 image-domain capabilities (resize / compress / convert / crop / rotate / flip / watermark / setBackground / filter) + 1 EXIF MetadataReader. The video/PDF/audio engines are stubs to be wired up in Phase 2. See
docs/capabilities.md.
Streaming Type Contracts (W3.1)
Section titled “Streaming Type Contracts (W3.1)”engine-image/src/types.ts defines streaming interfaces aimed at future WASM engines (line-by-line / chunked encoding/decoding):
ImageTile/ImageChunk: tile geometry and encoded output, used by the tiled pipeline to process per-tile.StreamingImageOperation: streaming operation signature taking aReadableStream<ImageTile>as input and aWritableStream<ImageChunk>as output.StreamingImageEngineAdapter: contract for the streaming engine adapter layer (splitInput/processTile/mergeChunks).
The Canvas engine cannot stream in the true sense (calling createImageBitmap decodes the whole image at once), but the type contracts reserve integration points for future Squoosh/WebCodecs WASM engines. The current Canvas engine approximates peak-memory control through the tiling infrastructure.
Tiling Infrastructure (W3.2)
Section titled “Tiling Infrastructure (W3.2)”engine-image/src/operations/tiles.ts provides tiling primitives:
splitIntoTiles(width, height, tileSize=512): slices the image into a row-major grid, with edge tiles aligned to the image boundary. Pure function, easy to unit-test and produce deterministic regressions.mergeChunks(chunks, totalW, totalH, format, quality): merges the encoded tile outputs back into a single Blob (decode → drawImage onto an output canvas → encode).isDownscale(srcW, srcH, targetW, targetH): determines whether this is a downscale, used to decide whether to take thedecodeResizedpath.
canvas-engine.ts adds decodeResized(blob, targetW, targetH): uses the resizeWidth/resizeHeight options of createImageBitmap to scale during decoding, avoiding the decode-then-scale of a full-resolution bitmap — this is the single biggest memory optimization in the Canvas engine. Browsers that don’t support the resize options fall back to a plain decode automatically.
Design note: resize follows the standard decode → computeTargetSize → drawImage path (single decode);
decodeResizedis exposed as a primitive for future “explicit maxEdge”-style APIs or WASM engines. What W3.2 actually delivers is the tiling infrastructure plus the streaming type contracts.
Memory Guard (W3.3)
Section titled “Memory Guard (W3.3)”runtime/src/memory-guard.ts tracks intermediate-result occupancy and triggers OPFS spillover as the budget is approached:
- Explicit tracked counters: instead of relying on
performance.memory(Chrome-only, deprecated, MB granularity), the caller explicitly registers bytes viatrack(bytes)at decode/canvas output and reverts viarelease()(idempotent). - Four pressure levels:
low(<60%) /elevated(60–80%) /high(80–95%) /critical(≥95%), based ontracked / budget. - OPFS spillover:
shouldSpill()returns true athigh/critical;spill(blob)writes intermediate Blobs to the AssetStore (OPFS-backed),restore(asset)reads them back on demand, andevict(asset)reclaims space. “Treat OPFS as virtual memory” — only the one or two currently-active Blobs stay in memory. - Estimation helper:
estimateDecodedBytes(w, h)(w×h×4 RGBA) is used to track the real footprint of decode results (a 4000×3000 JPEG blob might be only 2 MB, but occupies 46 MB after decode).
Degradation Ladder (W3.4)
Section titled “Degradation Ladder (W3.4)”runtime/src/degradation.ts implements a four-tier degradation policy; the decision is a pure function pickDegradation(ctx):
| Level | Trigger | Strategy |
|---|---|---|
| L1-full | pressure = low / elevated | Full quality, normal processing |
| L2-tiled | pressure = high + operation is tileable | Tiled processing + OPFS spillover of intermediate results |
| L3-degraded | pressure = high but not tileable, or critical | Degraded output: scale to maxEdge (4096) + quality 70 |
| L4-reject | critical and even degradation cannot save it | Reject + throw DegradationRejectedError carrying user guidance |
L4 trigger conditions: the input blob itself exceeds the budget and cannot be spilled, or the post-decode bitmap scaled to maxEdge is still estimated at ≥ 0.95×budget. DegradationRejectedError carries a guide array (user-readable suggestions: use a smaller source image / close other tabs / use the desktop app) that the UI can render directly. applyDegradationToResizeParams applies the L3 decision to resize params (injecting a maxEdge ceiling).
Cancel Throughout (W3.5)
Section titled “Cancel Throughout (W3.5)”An AbortSignal flows all the way from the executor to engine operations, so cancel() actually aborts in-flight work rather than merely setting a flag:
- executor → plugin-image:
execCtx.signalis passed intowrapAsImplementation.execute. - plugin-image → engine-image: the
ImageOperationtype signature includessignal?: AbortSignal, andwrapAsImplementationforwardsexecCtx.signalto each operation. - engine-image operations: all operations (resize/crop/rotate/flip/compress/convert/watermark/setBackground/filter) call
throwIfAborted(signal)at decode / draw / encode boundaries. The binary-search loop incompressToTargetSize(6 rounds) checks before each encode to avoid expensive canvas encoding after cancellation. Thefetch(imageUrl)inwatermarkalso takes the signal to enable network-layer cancellation. - worker-host → worker-adapter:
WorkerHost.request()acceptssignal?: AbortSignal; on abort it sends aWorkerCancel { type: 'cancel', id }message and immediately rejects withWorkerRequestAbortedError(without waiting for the Worker to respond). An already-aborted signal throws before sending (pre-flight check). - worker-adapter:
startImageWorkermaintains an in-flightMap<id, AbortController>; on receiving acancelmessage it callscontroller.abort()to abort the corresponding operation;dispatchImageMethodandcreateImageWorkerHandlerpass the signal down to the operation.
throwIfAborted(signal) throws DOMException('AbortError'), consistent with the Web platform convention; the upper-layer executor already maps the cancelled state to WorkflowResult.cancelled.
Workflow Orchestration (W10–W11)
Section titled “Workflow Orchestration (W10–W11)”Year 1 supports only linear workflows (up to 5 steps). WorkflowBuilder provides a chainable API: add(capability, params) / remove(nodeId) / move(from, to) / swap(a, b) / updateParams(nodeId, params); it enforces the 5-step ceiling internally (MAX_WORKFLOW_STEPS = 5). build() outputs a Workflow object, validated by validateWorkflow() for empty nodes / input-output connections / capability compatibility between adjacent nodes (three layers: input node inputTypes vs. workflow.inputs.type / adjacent node outputTypes∩inputTypes / output node outputTypes).
On the UI side, @lokvis/ui-react provides:
WorkflowEditor.tsx: HTML5 Drag and Drop API for drag reordering + ← → keyboard movement + Delete to remove + InsertConnector hover insertionuseWorkflows.ts: 5 free slots / unlimited for Pro + localStorage persistence + JSON import/exportuseShareLink.ts:?workflow=<base64url>share links (UTF-8 safe, handles Chinese watermarks)data/workflow-templates.ts: 5 built-in templates (web-optimize / social-batch / ecommerce-main / print-prep / screenshot-compress)
Versions and Compatibility (Alpha)
Section titled “Versions and Compatibility (Alpha)”- Runtime version:
0.1.0(theRUNTIME_VERSIONconstant) - Workflow schema:
v1(share links?workflow=<base64url>contain{v:1, nodes:[...]}) - MCP manifest version:
2025-06-18(MCP spec draft) - Worker protocol: at handshake the Worker sends
readycarrying the protocol version; the Host validates it before communication begins - Engine version:
IMAGE_ENGINE.version = '0.1.0'; stub engines have aversioncontaining the'stub'marker, which the Plugin layer’sbuildXxxCapabilityImplementations()detects (viaengine.version.includes('stub')) and then setsCapabilityImplementation.status = 'stub';CapabilityRegistry.resolve()skips them automatically
Known limitations of Phase 1 Alpha (none block Alpha; see docs/reports/W12.1-alpha-acceptance.md §5):
- L1: Video/PDF/Audio engines are stubs (Phase 2 wires up ffmpeg.wasm / pdf-lib)
- L2: Plugin SDK is Alpha-only (ADR-O2; third-party plugins not yet available, Phase 2 prioritizes the MCP server)
- L3: Real-browser Lighthouse not yet run (a W16.5 task after playground deployment)
- L4: Internal Alpha test of 5 people for 1 day not executed (W11.7 non-code task, run during the W12.7 buffer)
- L5: Sentry monitoring is wired up but has no DSN configured (W12.3 landed; inject
PUBLIC_SENTRY_DSNat deploy time)