Skip to content

Architecture

Lokvis follows a strict five-layer architecture with one-way dependencies. See docs/business/04-技术架构设计.md for the full spec.

Astro 7 + React 19 + Tailwind v4. Renders the Workspace UI and tool pages.

Linear workflow executor (Year 1 only). Each workflow is a chain of transform nodes connected by edges. No branches, loops, conditions, or parallelism yet.

The “browser operating system”. Single responsibility: Input → Run → Output. Manages assets, capabilities, history, and event bus. Never knows about React.

<domain>.<action> naming convention (e.g. image.resize, video.transcode). Runtime never knows about FFmpeg — only about capabilities.

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.

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”
  1. Runtime (@lokvis/runtime) — topological-sorts the workflow nodes, resolves each node’s capability against the CapabilityRegistry, and drives the WorkflowExecutor. It only ever holds AssetIds; raw Blobs live in the AssetStore (OPFS → IDB → memory fallback).

  2. Capability (@lokvis/capability + @lokvis/schema) — CapabilityRegistry.resolve(name) returns the best CapabilityImplementation for a capability name, skipping stub implementations automatically. The Capability type itself is a pure declarative contract (name / inputTypes / outputTypes / params schema) owned by Schema, so any layer can depend on it without pulling in Runtime.

  3. Plugin (@lokvis/plugin-*) — the CapabilityImplementation.execute that the registry returns is built by a plugin via createBlobCapabilityImpl(). The plugin’s operation(blob, params, signal) is where Asset ↔ Blob conversion happens: it pulls a Blob from 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).

  4. Engine (@lokvis/engine-*) — a pure Blob → Blob function (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.

The arrow Runtime → Capability → Engine (with Plugin bridging Capability and Engine) is enforced at the package level via package.json dependencies. Concretely:

  • @lokvis/runtime never imports @lokvis/engine-image — it only resolves capability names and calls impl.execute().
  • @lokvis/engine-image never imports @lokvis/runtime or @lokvis/schema — it is a set of pure functions.
  • A plugin (@lokvis/plugin-image) imports both its engine and @lokvis/capability to glue them together, but Runtime never imports the plugin directly; plugins are registered at runtime via installPlugin().

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.

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.
  • EngineIMAGE_ENGINE descriptor + standalone decodeImage/encodeImage primitives, Record<string, any> operation signature rule, Canvas implementation + decodeResized, streaming type contracts, Worker integration, stub engine pattern.
  • CapabilityCapability shape, 8 CapabilityParam types, capability catalog (image real / video-pdf stub), CapabilityRegistry.resolve three-stage flow, three-layer workflow validation, MCP manifest generation.
  • Plugin SDKdefinePlugin + createBlobCapabilityImpl factories, PluginContext restricted Runtime view, lifecycle, MetadataReader, stub plugin pattern, Panels, Vitest testing pattern.
  • lokvis-open never imports from lokvis-cloud
  • Runtime never knows about React / Redux
  • Schema never knows about Runtime
  • Plugin never knows about Cloud

Metadata in IndexedDB (via Dexie in future), large files in OPFS (Origin Private File System). Runtime only manipulates AssetIds — never raw Blobs.

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):

sequenceDiagram participant H as WorkerHost(主线程) participant W as Worker participant T as WorkerTransport Note over H,W: 1. 启动与握手 H->>T: createTransport() H->>T: onMessage / onError H->>H: 等待 ready(超时 10s) W-->>H: { type: "ready", protocolVersion: "0.1.0" } H->>H: 校验版本 → status: ready H->>H: startHeartbeat(间隔 5s) Note over H,W: 2. 心跳保活 H-->>W: { type: "ping", id, ts } W-->>H: { type: "pong" } Note over H: 收到 pong → 清除超时定时器 Note over H,W: 3. 请求/响应 H-->>W: { type: "request", id, method, params } Note over H: 记录 pending[id] + 独立超时(60s) W-->>H: { type: "response", id, ok: true, result } H->>H: 按 id 关联 → resolve Promise Note over H,W: 4. 崩溃与重启 W--xH: 传输层 error / 心跳超时(15s 无 pong) H->>H: handleCrash: status=restarting H->>H: rejectAllPending(WorkerCrashedError) H->>T: terminate() Note over H: restartCount < maxRestarts(3)? H->>T: createTransport() 重建 W-->>H: { type: "ready" } 握手 H->>H: emit("restart") → status: ready
  • 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 with pong within 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 maxRestarts times, default 3). When the limit is exceeded it enters dead state and the upper layer handles degradation.
  • Transport abstraction: The WorkerTransport interface 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):

stateDiagram-v2 [*] --> idle: 构造 idle --> ready: init() → spawn() → ready 握手成功 idle --> [*]: 握手超时(抛 WorkerHandshakeError) ready --> restarting: handleCrash(传输层 error / 心跳超时) ready --> disposed: dispose() restarting --> ready: spawn 成功 → emit("restart") restarting --> restarting: spawn 失败 → tryRestart 递归(restartCount++) restarting --> dead: restartCount >= maxRestarts restarting --> disposed: dispose() dead --> [*]: 上层降级处理 disposed --> [*]: 终止 transport + 清理定时器

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):

flowchart TD A[Worker 启动] --> B[startImageWorker scope] B --> C[发送 ready 携带 PROTOCOL_VERSION] C --> D{绑定 scope.onmessage} D --> E{消息分发} E -->|ping| F[回复 pong] E -->|request| G[createImageWorkerHandler] E -->|其他| H[忽略] G --> I{解析 method} I -->|image.resize| J1[operations.resize] I -->|image.compress| J2[operations.compress] I -->|image.convert| J3[operations.convert] I -->|...| J4[其他 8 个方法] I -->|未知 method| K[回复 ok=false 错误] J1 --> L[回复 response ok=true result] J2 --> L J3 --> L J4 --> L subgraph 依赖约束 M[worker-adapter] --> N["@lokvis/schema 仅类型"] N -.->|不依赖| O["@lokvis/runtime"] end

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:

flowchart LR subgraph HistoryStack direction TB E0["entries[0] (最旧)"] E1["entries[1]"] E2["entries[2]"] E3["entries[3] (最新)"] E0 --- E1 --- E2 --- E3 CUR["cursor = 3 (指向最后已应用)"] CUR -.- E3 EV["onEvict 回调 (LRU 淘汰时触发)"] EV -.->|清理 outputs 资产| OPFS[(OPFS / IDB)] BUS["eventBus emit history:changed"] end U[undo: cursor-- → 返回 entry.inputs] R[redo: cursor++ → 返回 entry.outputs] A[append: 截断 cursor 之后 → push → LRU 检查] U --> HistoryStack R --> HistoryStack A --> HistoryStack

append / undo / redo and redo-branch truncation flow:

flowchart TD Start([操作请求]) --> Op{操作类型} Op -->|append| A1{cursor < length-1? 有 redo 分支?} A1 -->|是| A2[截断 cursor 之后的条目] A1 -->|否| A3 A2 --> A3[push 新 entry] A3 --> A4{length > maxEntries?} A4 -->|是| A5[shift 最旧条目 cursor-- 调用 onEvict] A4 -->|否| A6 A5 --> A6[cursor = length-1] A6 --> A7[emit history:changed] Op -->|undo| U1{cursor >= 0?} U1 -->|否| U2[返回 null] U1 -->|是| U3[entry = entries cursor cursor--] U3 --> U4[返回 entry.inputs] U4 --> U5[emit history:changed] Op -->|redo| R1{cursor < length-1?} R1 -->|否| R2[返回 null] R1 -->|是| R3[cursor++ entry = entries cursor] R3 --> R4[返回 entry.outputs] R4 --> R5[emit history:changed]

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:

flowchart TD Start([createAssetStore options]) --> O1{preferOpfs && isOpfsAvailable?} O1 -->|是| O2[createOpfsAssetStore] O2 --> O3{初始化成功?} O3 -->|是| O4[返回 OPFS Store path: opfs://] O3 -->|否| O5 O1 -->|否| O5{allowIdbFallback && indexedDB 存在?} O5 -->|是| O6[createIdbAssetStore Dexie 双表] O6 --> O7{初始化成功?} O7 -->|是| O8[返回 IDB Store path: idb://] O7 -->|否| O9 O5 -->|否| O9[createMemoryAssetStore 最终降级] O9 --> O10[返回内存 Store path: memory://] O4 --> End([AssetStore 实例]) O8 --> End O10 --> End

Data layout for the OPFS / IDB / memory backends:

flowchart LR subgraph OPFS["opfs:// (L1 最优)"] direction TB OP1["navigator.storage.getDirectory()"] OP2["lokvis-assets/ 目录"] OP3[" 文件 (Blob 原始字节)"] OP4["内存 Map 缓存 Asset 元数据"] OP1 --> OP2 --> OP3 OP3 -.-> OP4 end subgraph IDB["idb:// (L2 持久化降级)"] direction TB ID1["Dexie: lokvis-asset-store"] ID2["assets 表 (Asset 元数据, 主键 id)"] ID3["blobs 表 (ArrayBuffer, 主键 id)"] ID1 --> ID2 ID1 --> ID3 ID2 <-.->|事务一致性| ID3 end subgraph MEM["memory:// (L3 最终降级)"] direction TB ME1["Map"] ME2["Map"] ME1 -.-> ME2 end RT[AssetStore 接口 import / get / getBlob / remove / list / create] --> OPFS RT --> IDB RT --> MEM

Runtime run() with quota validation and history wiring flow:

sequenceDiagram participant Caller as 调用方 participant RT as LokvisRuntimeImpl participant Q as checkStorageQuota participant EX as WorkflowExecutor participant AS as AssetStore participant HS as HistoryStack participant EB as EventBus Caller->>RT: run(workflow, inputs) RT->>Q: checkStorageQuota(storageQuota) alt 已用量超限 Q-->>RT: throw QuotaExceededError RT-->>Caller: 抛错 (status=error) else 配额充足 Q-->>RT: 通过 RT->>EX: execute(workflow, inputs) loop 每个 transform 节点 EX->>AS: getBlob / create (处理资产) EX->>EB: emit node:started / node:finished end EX-->>RT: WorkflowResult (status=completed) alt 执行成功 RT->>HS: getOrCreateHistoryStack(workflowId) loop 每个 transform 节点 RT->>HS: append(HistoryEntry) HS->>EB: emit history:changed end Note over HS: 若超出 maxEntries onEvict 清理 outputs 资产 HS->>AS: remove(evicted.outputs) [可选] end RT-->>Caller: WorkflowResult end

undo/redo end-to-end flow (including cursor movement and event emission):

flowchart TD Run([run 完成 cursor=N-1]) --> Idle([用户查看结果]) Idle --> Undo{调用 undo?} Undo -->|是| U1[getOrCreateHistoryStack] U1 --> U2{canUndo? cursor >= 0} U2 -->|否| U3[无操作返回] U2 -->|是| U4[entry = entries cursor cursor--] U4 --> U5[emit history:changed] U5 --> U6[返回 entry.inputs UI 恢复为上一步资产] U6 --> Idle Idle --> Redo{调用 redo?} Redo -->|是| R1[getOrCreateHistoryStack] R1 --> R2{canRedo? cursor < length-1} R2 -->|否| R3[无操作返回] R2 -->|是| R4[cursor++ entry = entries cursor] R4 --> R5[emit history:changed] R5 --> R6[返回 entry.outputs UI 恢复为该步结果] R6 --> Idle Idle --> NewOp{执行新操作?} NewOp -->|是| N1[run → recordHistory] N1 --> N2[append 新 entry] N2 --> N3{cursor < length-1? 有 redo 分支?} N3 -->|是| N4[截断 redo 分支 丢弃未来条目] N3 -->|否| N5[push] N4 --> N5 N5 --> N6[emit history:changed] N6 --> Idle

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.

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 a ReadableStream<ImageTile> as input and a WritableStream<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.

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 the decodeResized path.

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); decodeResized is 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.

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 via track(bytes) at decode/canvas output and reverts via release() (idempotent).
  • Four pressure levels: low (<60%) / elevated (60–80%) / high (80–95%) / critical (≥95%), based on tracked / budget.
  • OPFS spillover: shouldSpill() returns true at high/critical; spill(blob) writes intermediate Blobs to the AssetStore (OPFS-backed), restore(asset) reads them back on demand, and evict(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).

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).

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:

  1. executor → plugin-image: execCtx.signal is passed into wrapAsImplementation.execute.
  2. plugin-image → engine-image: the ImageOperation type signature includes signal?: AbortSignal, and wrapAsImplementation forwards execCtx.signal to each operation.
  3. 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 in compressToTargetSize (6 rounds) checks before each encode to avoid expensive canvas encoding after cancellation. The fetch(imageUrl) in watermark also takes the signal to enable network-layer cancellation.
  4. worker-host → worker-adapter: WorkerHost.request() accepts signal?: AbortSignal; on abort it sends a WorkerCancel { type: 'cancel', id } message and immediately rejects with WorkerRequestAbortedError (without waiting for the Worker to respond). An already-aborted signal throws before sending (pre-flight check).
  5. worker-adapter: startImageWorker maintains an in-flight Map<id, AbortController>; on receiving a cancel message it calls controller.abort() to abort the corresponding operation; dispatchImageMethod and createImageWorkerHandler pass 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.

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 insertion
  • useWorkflows.ts: 5 free slots / unlimited for Pro + localStorage persistence + JSON import/export
  • useShareLink.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)
  • Runtime version: 0.1.0 (the RUNTIME_VERSION constant)
  • 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 ready carrying the protocol version; the Host validates it before communication begins
  • Engine version: IMAGE_ENGINE.version = '0.1.0'; stub engines have a version containing the 'stub' marker, which the Plugin layer’s buildXxxCapabilityImplementations() detects (via engine.version.includes('stub')) and then sets CapabilityImplementation.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_DSN at deploy time)