Compare commits

...
1 Commits
Author SHA1 Message Date
Brandon Thomas 84519e0e68 performance changes 2026-09-26 17:49:39 -07:00
13 changed files with 1851 additions and 70 deletions
+219
View File
@@ -0,0 +1,219 @@
# Desktop frontend performance experiments
Measured September 26, 2026 on an Apple M4 Pro, macOS arm64, Chrome
153.0.8010.53, Node 24.13.0. These are production frontend measurements in
headless Chrome with mocked Tauri services. They do **not** measure native
process launch, WKWebView/WebView2, Rust, disk startup, real account loading,
or generation latency.
## Changes and results
Seven samples per version; table values are medians. Each sample uses a fresh
browser context at 1440 × 1000 with device scale 1 and browser HTTP cache
disabled. The browser process stays alive between samples. A reload in the
same context is measured separately. There is no CPU/network throttling.
External requests, including analytics, fonts, and media, are blocked in both
versions. The login fixture is signed in; the bundled model overlay is used.
| Measurement | Before | After | Reduction |
|-----------------------------|-----------|-----------|-----------|
| Startup to frame | 566.40 ms | 350.60 ms | 38.1% |
| Refresh to frame | 248.60 ms | 198.10 ms | 20.3% |
| Startup JavaScript | 9.05 MB | 6.47 MB | 28.6% |
| Startup long tasks (sum) | 336.00 ms | 184.00 ms | 45.2% |
| First drawing page to frame | 120.90 ms | 118.20 ms | 2.2% |
| Drawing to home: dispatch | 23.40 ms | 9.00 ms | 61.5% |
| Drawing to home: frame | 29.50 ms | 23.00 ms | 22.0% |
| Home to drawing: dispatch | 42.10 ms | 18.80 ms | 55.3% |
| Home to drawing: frame | 105.70 ms | 86.50 ms | 18.2% |
The 2.2% first-open difference is small enough to treat as unchanged. These
local measurements are evidence of reduced frontend work, not predictions
of identical percentage gains on every user's machine. Raw samples, ranges,
environment, fixture sizes, browser errors, and build provenance are in
[before.json](performance/2026-09-26-before.json) and
[after.json](performance/2026-09-26-after.json).
Three changes produced these results:
1. `MainApp.tsx` imported every editor at launch. Fifteen pages now use
`React.lazy` behind a Suspense boundary below the persistent shell. Their
code loads when selected; the top bar stays available during loading.
The default Apps page remains eager. Drawing also remains eager: its
shared code is already part of the shell, and experimentally deferring
its small host adapter made first-open latency worse for little saving.
First-open latency for the other deferred editors is a remaining tradeoff
to measure, especially on slower disks/CPUs. All 15 deferred modules were
imported successfully in the browser, but this is not an end-to-end test
of every editor's features.
2. `TabState.ts` exported the drawing scene to JSON when leaving and imported
it when returning. The Zustand scene store already survives page unmount.
With four imported images, the old path performed four FileReader reads
on exit and four more on return, allocated base64 copies, rebuilt image
nodes, cleared the selection, and appended an undo-history entry. Tab
changes now keep the live store. The measured round trip performs **zero
image-file reads**, preserves the selected node, and keeps history length
at one instead of growing to two. Explicit file export/import remains
available. Tests cover image identity, model metadata, selection, redo,
gallery reset, rapid navigation, and portable file export.
3. The shell ran `getGPUTier()` at startup and stored its result in an unread
React state value. Removing it eliminates **one startup WebGL context
request** and its associated detection work. The real 3D renderer still
creates its context when used. This experiment reports combined timing
gains, not an isolated timing attribution for each individual change.
## Repeat the experiment
From the repository root:
```sh
cd frontend
npm ci
npm run perf:desktop -- --output /tmp/artcraft-perf-current --smoke-imports
```
The default browser is an installed Google Chrome (`--channel chrome`). The
runner requires permission to launch a browser and bind a loopback server.
It never uses your regular browser profile, account, or native app data.
`@playwright/test` is pinned as a development dependency. Artifacts are written
outside the repository by default. Use a dedicated output directory: its
`dist` subdirectory is rebuilt by Vite.
For this particular two-module change, reproduce the original implementation
without replacing local source files:
```sh
npm run perf:desktop -- --output /tmp/artcraft-perf-before \
--baseline-ref 780e4494e995163fed1d677b88c3cf03b3fca63c
npm run perf:desktop -- --output /tmp/artcraft-perf-after --smoke-imports
node tools/performance/compare.mjs \
/tmp/artcraft-perf-before/results.json \
/tmp/artcraft-perf-after/results.json
```
`--baseline-ref` overlays **only** `MainApp.tsx` and `pages/Stores/TabState.ts`
from that Git revision in the benchmark build. All other source files and
dependencies remain current. It reproduces the two production files changed
in this experiment; it is not a general historical-checkout benchmark. For
future changes, capture a normal baseline **before** editing, use a new output
directory for the changed version, and keep the runner, fixture, dependencies,
browser, and machine identical. Run sequentially while the machine is idle;
do not build or run other CPU-heavy tasks during samples. Repeat in reverse
order if results are close or noisy.
Useful options:
```sh
# Build in a restricted shell, then run where browser/server use is allowed.
npm run perf:desktop -- --output /tmp/artcraft-perf-current --build-only
npm run perf:desktop -- --output /tmp/artcraft-perf-current --skip-build
# More samples; seven is the default.
npm run perf:desktop -- --output /tmp/artcraft-perf-current --runs 15
# Correctness checks for the navigation change.
npx vitest run --config apps/artcraft/vitest.config.ts
```
Do not use `--skip-build` after changing app code; it intentionally measures
the existing artifact. `build-info.json` records the revision, changed-file
names, baseline overlay, and build time. The benchmark plugin alone exposes
the stores needed to seed the scene. It is not part of the normal Vite/Tauri
build, and no mock IPC or benchmark global is added to the shipped app.
The runner builds from the app's working directory, matching its normal
build command. This matters because Tailwind resolves its config and content
relative to that directory. It rejects a run without working Tailwind styles
or with uncaught browser errors. `drawing.png` provides a visual check after
the measured operations. The initial unstyled exploratory runs were discarded.
## What the measurements mean
The fixture creates four deterministic 1024 × 1024 PNG image nodes and 80
strokes of 200 coordinates each, with one selected image. On the recorded
browser each PNG is 2,356,706 bytes. Fixture construction, image decoding,
and a one-second settling period are outside the tab-transition timings.
Each sample follows the same sequence:
1. Navigate to the production app, wait for the Apps heading and for the
signed-in login overlay to disappear, then wait two animation frames.
2. Reload that page and repeat the same readiness checks.
3. Seed the scene while on Apps, open drawing, and wait for its canvas and
two animation frames. Allow a second for its background bake to settle.
4. Switch to Apps and then back to drawing, measuring each separately.
5. After a settling period, inspect node count, selection, history, and IPC
counters. Save a screenshot on the first run. Optionally evaluate all
deferred page modules after the final timed operation.
`readyMs` is `performance.now()` after readiness checks and two animation
frames; its origin is navigation start. It includes browser loading and
automation readiness-observation overhead. `jsBytes` sums decoded resource
sizes of loaded JavaScript; it is uncompressed bytes, not all packaged files.
`longTaskMs` sums observed startup tasks longer than 50 ms, not total CPU time
or total blocking time. Long Tasks support is browser-dependent.
`stateReadyMs` measures the awaited `setActiveTab()` action, including React
work synchronously flushed by its store subscribers. `paintMs` continues
until the destination heading/canvas exists and two animation frames have
passed. This is a **rendering-opportunity proxy**, not proof of GPU presentation
or of every asynchronous image decode completing. In particular, the old
return path could still be decoding images after the canvas first appeared.
Tab measurements also create `artcraft:tab:APPS` and `artcraft:tab:2D` User
Timing entries in the benchmark page.
The runner calls the same store action used by navigation UI. It excludes
pointer-event dispatch, menu animations, and physical click-to-screen latency.
It does not automate paid generations, sign in to a real account, or measure
remote services. Save the raw JSON as well as the summary; a single minimum
time is not a reliable comparison.
`dist/performance-bundle.json` ranks the largest retained modules per chunk
and records chunk imports. Module `renderedBytes` is Rollup's intermediate
module size; it is useful for attribution but should not be summed as final
minified/gzipped size. Use actual resource bytes for the startup total.
## Remaining investigation
- The shell still loads about 6.47 MB of JavaScript. Shared imports through
drawing, scene, gallery, and settings entry points need a deeper dependency
audit. Separate lightweight store/type imports from editor code, then use
the bundle report and the same startup experiment to verify what actually
leaves the initial graph. The report identifies Spark, Three.js core and
renderer, the inline FBX-to-GLB conversion worker, PostHog, and Mediabunny
among the largest retained modules. Their presence is measured; the runtime
benefit of deferring each individually has not yet been measured.
- Returning to the populated canvas still takes about 86 ms. The editor
remount, Konva drawing, image/transformer setup, and composite bake need a
timeline profile before choosing the next change. Keep scene restoration,
undo, selection, and background generation behavior in correctness checks.
- `TaskQueue.tsx` polls every five seconds and rebuilds task arrays even when
the popover is closed. The benchmark uses an empty queue, so it does not
establish the cost for a large real queue. A fixed, representative task
fixture plus idle CPU/render counts is the next useful experiment there.
- `useStageSnapshot` looks expensive in isolation, but its only call is
commented out in `PaintSurface.tsx`. It was excluded as a current bottleneck.
Validation completed: normal production Vite build, all 10 app tests and 20
login tests, all 15 deferred page-module imports, visual canvas inspection,
and a check that the normal build contains no benchmark globals or fixtures.
The broad TypeScript check remains blocked by existing workspace declaration/
project-reference errors and Remix/Deno types. Comparing diagnostics with the
two original modules showed the same diagnostic messages (some import traces
and reporting locations changed); this is not a clean typecheck claim.
## Validate in the native app
Before claiming native launch or interaction gains, run matching release
Tauri builds on the same machine and WebView version. Keep account, window
size, network conditions, and canvas artwork fixed. Record the Web Inspector
timeline for launch/reload, opening a tool for the first time, drawing → Apps,
and Apps → drawing. Repeat at least seven times and retain the traces with
build IDs and medians. Separate native process launch from page reload; do
not mix dev/HMR traces with production measurements.
For canvas tests, create a blank 1024-square canvas, import the same four
images, add the same strokes, select an image, and wait for background work
before switching. Check image content, selection, undo/redo, and opening a new
gallery image afterward. Record actual click-to-visible-frame latency in
the inspector as well as JavaScript tasks. The browser results above justify
the code changes but do not replace that native validation.
+527
View File
@@ -0,0 +1,527 @@
{
"timestamp": "2026-09-27T00:32:54.524Z",
"build": {
"builtAt": "2026-09-27T00:30:35.760Z",
"revision": "780e4494e995163fed1d677b88c3cf03b3fca63c",
"baselineOverlay": null,
"changedFiles": [
"frontend/apps/artcraft/app/src/pages/MainApp.tsx",
"frontend/apps/artcraft/app/src/pages/Stores/TabState.ts",
"frontend/package-lock.json",
"frontend/package.json"
]
},
"environment": {
"platform": "darwin",
"arch": "arm64",
"cpu": "Apple M4 Pro",
"browser": "153.0.8010.53",
"node": "v24.13.0",
"runs": 7,
"viewport": "1440x1000@1",
"ipc": "mocked",
"network": "local assets only, browser cache disabled"
},
"summary": {
"startup": {
"readyMs": {
"median": 350.60000002384186,
"min": 348.7000000476837,
"max": 457.39999997615814
},
"jsBytes": {
"median": 6466606,
"min": 6466606,
"max": 6466606
},
"longTaskMs": {
"median": 184,
"min": 179,
"max": 284
},
"webglContexts": {
"median": 0,
"min": 0,
"max": 0
}
},
"refresh": {
"readyMs": {
"median": 198.10000002384186,
"min": 197.10000002384186,
"max": 198.80000007152557
},
"jsBytes": {
"median": 6466606,
"min": 6466606,
"max": 6466606
},
"longTaskMs": {
"median": 0,
"min": 0,
"max": 0
},
"webglContexts": {
"median": 0,
"min": 0,
"max": 0
}
},
"firstDraw": {
"stateReadyMs": {
"median": 38.300000071525574,
"min": 37.60000002384186,
"max": 40.299999952316284
},
"paintMs": {
"median": 118.19999992847443,
"min": 115.30000007152557,
"max": 120
},
"fileReads": {
"median": 0,
"min": 0,
"max": 0
}
},
"leaveDraw": {
"stateReadyMs": {
"median": 9,
"min": 7.5,
"max": 11
},
"paintMs": {
"median": 23,
"min": 19.600000023841858,
"max": 26.600000023841858
},
"fileReads": {
"median": 0,
"min": 0,
"max": 0
}
},
"returnDraw": {
"stateReadyMs": {
"median": 18.800000071525574,
"min": 15.799999952316284,
"max": 20.700000047683716
},
"paintMs": {
"median": 86.5,
"min": 78.79999995231628,
"max": 90.10000002384186
},
"fileReads": {
"median": 0,
"min": 0,
"max": 0
}
}
},
"samples": [
{
"startup": {
"readyMs": 457.39999997615814,
"jsBytes": 6466606,
"longTaskMs": 284,
"webglContexts": 0
},
"refresh": {
"readyMs": 197.80000007152557,
"jsBytes": 6466606,
"longTaskMs": 0,
"webglContexts": 0
},
"firstDraw": {
"stateReadyMs": 38.800000071525574,
"paintMs": 119.5,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 7.600000023841858,
"paintMs": 20.399999976158142,
"fileReads": 0
},
"returnDraw": {
"stateReadyMs": 17.799999952316284,
"paintMs": 87.79999995231628,
"fileReads": 0
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 1,
"historyLength": 1,
"commands": {
"plugin:event|listen": 25,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 365.7999999523163,
"jsBytes": 6466606,
"longTaskMs": 185,
"webglContexts": 0
},
"refresh": {
"readyMs": 197.10000002384186,
"jsBytes": 6466606,
"longTaskMs": 0,
"webglContexts": 0
},
"firstDraw": {
"stateReadyMs": 37.60000002384186,
"paintMs": 116,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 7.5,
"paintMs": 26.600000023841858,
"fileReads": 0
},
"returnDraw": {
"stateReadyMs": 15.799999952316284,
"paintMs": 78.79999995231628,
"fileReads": 0
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 1,
"historyLength": 1,
"commands": {
"plugin:event|listen": 25,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 366.59999990463257,
"jsBytes": 6466606,
"longTaskMs": 193,
"webglContexts": 0
},
"refresh": {
"readyMs": 198.39999997615814,
"jsBytes": 6466606,
"longTaskMs": 0,
"webglContexts": 0
},
"firstDraw": {
"stateReadyMs": 40.299999952316284,
"paintMs": 118.19999992847443,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 9,
"paintMs": 23,
"fileReads": 0
},
"returnDraw": {
"stateReadyMs": 19.699999928474426,
"paintMs": 86.5,
"fileReads": 0
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 1,
"historyLength": 1,
"commands": {
"plugin:event|listen": 25,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 348.7000000476837,
"jsBytes": 6466606,
"longTaskMs": 184,
"webglContexts": 0
},
"refresh": {
"readyMs": 197.89999997615814,
"jsBytes": 6466606,
"longTaskMs": 0,
"webglContexts": 0
},
"firstDraw": {
"stateReadyMs": 37.700000047683716,
"paintMs": 115.60000002384186,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 9.300000071525574,
"paintMs": 26.40000009536743,
"fileReads": 0
},
"returnDraw": {
"stateReadyMs": 18.700000047683716,
"paintMs": 84.90000009536743,
"fileReads": 0
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 1,
"historyLength": 1,
"commands": {
"plugin:event|listen": 25,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 349.60000002384186,
"jsBytes": 6466606,
"longTaskMs": 179,
"webglContexts": 0
},
"refresh": {
"readyMs": 198.10000002384186,
"jsBytes": 6466606,
"longTaskMs": 0,
"webglContexts": 0
},
"firstDraw": {
"stateReadyMs": 38.300000071525574,
"paintMs": 118.5,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 11,
"paintMs": 19.600000023841858,
"fileReads": 0
},
"returnDraw": {
"stateReadyMs": 20.700000047683716,
"paintMs": 90.10000002384186,
"fileReads": 0
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 1,
"historyLength": 1,
"commands": {
"plugin:event|listen": 25,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 350.60000002384186,
"jsBytes": 6466606,
"longTaskMs": 179,
"webglContexts": 0
},
"refresh": {
"readyMs": 198.20000004768372,
"jsBytes": 6466606,
"longTaskMs": 0,
"webglContexts": 0
},
"firstDraw": {
"stateReadyMs": 39.60000002384186,
"paintMs": 120,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 11,
"paintMs": 22.199999928474426,
"fileReads": 0
},
"returnDraw": {
"stateReadyMs": 20,
"paintMs": 87,
"fileReads": 0
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 1,
"historyLength": 1,
"commands": {
"plugin:event|listen": 25,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 349.89999997615814,
"jsBytes": 6466606,
"longTaskMs": 180,
"webglContexts": 0
},
"refresh": {
"readyMs": 198.80000007152557,
"jsBytes": 6466606,
"longTaskMs": 0,
"webglContexts": 0
},
"firstDraw": {
"stateReadyMs": 38,
"paintMs": 115.30000007152557,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 8.200000047683716,
"paintMs": 24.899999976158142,
"fileReads": 0
},
"returnDraw": {
"stateReadyMs": 18.800000071525574,
"paintMs": 84.5,
"fileReads": 0
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 1,
"historyLength": 1,
"commands": {
"plugin:event|listen": 25,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
}
],
"importedPages": [
"src/pages/PageAngles/index.ts",
"src/pages/PageAudio/CreateAudio.tsx",
"src/pages/PageBackgroundChange/index.ts",
"src/pages/PageImage/TextToImage.tsx",
"src/pages/PageImageTo3DObject/index.ts",
"src/pages/PageImageTo3DWorld/index.ts",
"src/pages/PageImageWatermarkRemover/index.ts",
"src/pages/PageMoodboard/index.ts",
"src/pages/PageRemoveBackground/index.ts",
"src/pages/PageScene/index.tsx",
"src/pages/PageStoryboard/index.ts",
"src/pages/PageVideo/ImageToVideo.tsx",
"src/pages/PageVideoEditor/index.tsx",
"src/pages/PageVideoFrameExtractor/index.ts",
"src/pages/PageVideoWatermarkRemover/index.ts"
],
"errors": []
}
+510
View File
@@ -0,0 +1,510 @@
{
"timestamp": "2026-09-27T00:30:21.217Z",
"build": {
"builtAt": "2026-09-27T00:28:39.209Z",
"revision": "780e4494e995163fed1d677b88c3cf03b3fca63c",
"baselineOverlay": "HEAD",
"changedFiles": [
"frontend/apps/artcraft/app/src/pages/MainApp.tsx",
"frontend/apps/artcraft/app/src/pages/Stores/TabState.ts",
"frontend/package-lock.json",
"frontend/package.json"
]
},
"environment": {
"platform": "darwin",
"arch": "arm64",
"cpu": "Apple M4 Pro",
"browser": "153.0.8010.53",
"node": "v24.13.0",
"runs": 7,
"viewport": "1440x1000@1",
"ipc": "mocked",
"network": "local assets only, browser cache disabled"
},
"summary": {
"startup": {
"readyMs": {
"median": 566.3999999761581,
"min": 532.3999999761581,
"max": 847.7999999523163
},
"jsBytes": {
"median": 9052991,
"min": 9052991,
"max": 9052991
},
"longTaskMs": {
"median": 336,
"min": 314,
"max": 407
},
"webglContexts": {
"median": 1,
"min": 1,
"max": 1
}
},
"refresh": {
"readyMs": {
"median": 248.60000002384186,
"min": 247.59999990463257,
"max": 351.1999999284744
},
"jsBytes": {
"median": 9052991,
"min": 9052991,
"max": 9052991
},
"longTaskMs": {
"median": 0,
"min": 0,
"max": 0
},
"webglContexts": {
"median": 1,
"min": 1,
"max": 1
}
},
"firstDraw": {
"stateReadyMs": {
"median": 40.40000009536743,
"min": 37.299999952316284,
"max": 43.299999952316284
},
"paintMs": {
"median": 120.90000009536743,
"min": 117.89999997615814,
"max": 126.79999995231628
},
"fileReads": {
"median": 0,
"min": 0,
"max": 0
}
},
"leaveDraw": {
"stateReadyMs": {
"median": 23.399999976158142,
"min": 20.100000023841858,
"max": 26
},
"paintMs": {
"median": 29.5,
"min": 21.200000047683716,
"max": 35.5
},
"fileReads": {
"median": 4,
"min": 4,
"max": 4
}
},
"returnDraw": {
"stateReadyMs": {
"median": 42.10000002384186,
"min": 36.59999990463257,
"max": 45.200000047683716
},
"paintMs": {
"median": 105.69999992847443,
"min": 100.89999997615814,
"max": 109.20000004768372
},
"fileReads": {
"median": 4,
"min": 4,
"max": 4
}
}
},
"samples": [
{
"startup": {
"readyMs": 847.7999999523163,
"jsBytes": 9052991,
"longTaskMs": 407,
"webglContexts": 1
},
"refresh": {
"readyMs": 351.1999999284744,
"jsBytes": 9052991,
"longTaskMs": 0,
"webglContexts": 1
},
"firstDraw": {
"stateReadyMs": 43.299999952316284,
"paintMs": 126.79999995231628,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 25.100000023841858,
"paintMs": 35.5,
"fileReads": 4
},
"returnDraw": {
"stateReadyMs": 36.59999990463257,
"paintMs": 100.89999997615814,
"fileReads": 4
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 0,
"historyLength": 2,
"commands": {
"plugin:event|listen": 26,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 539.3999999761581,
"jsBytes": 9052991,
"longTaskMs": 317,
"webglContexts": 1
},
"refresh": {
"readyMs": 264.60000002384186,
"jsBytes": 9052991,
"longTaskMs": 0,
"webglContexts": 1
},
"firstDraw": {
"stateReadyMs": 39.39999997615814,
"paintMs": 120.20000004768372,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 20.100000023841858,
"paintMs": 21.200000047683716,
"fileReads": 4
},
"returnDraw": {
"stateReadyMs": 41.60000002384186,
"paintMs": 104.60000002384186,
"fileReads": 4
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 0,
"historyLength": 2,
"commands": {
"plugin:event|listen": 26,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 561.9000000953674,
"jsBytes": 9052991,
"longTaskMs": 329,
"webglContexts": 1
},
"refresh": {
"readyMs": 248.19999992847443,
"jsBytes": 9052991,
"longTaskMs": 0,
"webglContexts": 1
},
"firstDraw": {
"stateReadyMs": 40.40000009536743,
"paintMs": 120.90000009536743,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 23.399999976158142,
"paintMs": 33.39999997615814,
"fileReads": 4
},
"returnDraw": {
"stateReadyMs": 37.299999952316284,
"paintMs": 101.10000002384186,
"fileReads": 4
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 0,
"historyLength": 2,
"commands": {
"plugin:event|listen": 26,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 569.5,
"jsBytes": 9052991,
"longTaskMs": 336,
"webglContexts": 1
},
"refresh": {
"readyMs": 248,
"jsBytes": 9052991,
"longTaskMs": 0,
"webglContexts": 1
},
"firstDraw": {
"stateReadyMs": 38,
"paintMs": 119.30000007152557,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 26,
"paintMs": 34.799999952316284,
"fileReads": 4
},
"returnDraw": {
"stateReadyMs": 45.200000047683716,
"paintMs": 109.20000004768372,
"fileReads": 4
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 0,
"historyLength": 2,
"commands": {
"plugin:event|listen": 26,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 567.5,
"jsBytes": 9052991,
"longTaskMs": 339,
"webglContexts": 1
},
"refresh": {
"readyMs": 248.60000002384186,
"jsBytes": 9052991,
"longTaskMs": 0,
"webglContexts": 1
},
"firstDraw": {
"stateReadyMs": 40.89999997615814,
"paintMs": 122.10000002384186,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 23.200000047683716,
"paintMs": 29.5,
"fileReads": 4
},
"returnDraw": {
"stateReadyMs": 42.10000002384186,
"paintMs": 105.69999992847443,
"fileReads": 4
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 0,
"historyLength": 2,
"commands": {
"plugin:event|listen": 26,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 566.3999999761581,
"jsBytes": 9052991,
"longTaskMs": 336,
"webglContexts": 1
},
"refresh": {
"readyMs": 281.39999997615814,
"jsBytes": 9052991,
"longTaskMs": 0,
"webglContexts": 1
},
"firstDraw": {
"stateReadyMs": 43.09999990463257,
"paintMs": 124.69999992847443,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 23.399999976158142,
"paintMs": 26.199999928474426,
"fileReads": 4
},
"returnDraw": {
"stateReadyMs": 42.799999952316284,
"paintMs": 107.19999992847443,
"fileReads": 4
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 0,
"historyLength": 2,
"commands": {
"plugin:event|listen": 26,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
},
{
"startup": {
"readyMs": 532.3999999761581,
"jsBytes": 9052991,
"longTaskMs": 314,
"webglContexts": 1
},
"refresh": {
"readyMs": 247.59999990463257,
"jsBytes": 9052991,
"longTaskMs": 0,
"webglContexts": 1
},
"firstDraw": {
"stateReadyMs": 37.299999952316284,
"paintMs": 117.89999997615814,
"fileReads": 0
},
"leaveDraw": {
"stateReadyMs": 21.5,
"paintMs": 22.899999976158142,
"fileReads": 4
},
"returnDraw": {
"stateReadyMs": 44.09999990463257,
"paintMs": 108.39999997615814,
"fileReads": 4
},
"fixture": {
"images": 4,
"imageSize": 1024,
"imageBytesEach": 2356706,
"strokes": 80,
"coordinatesPerStroke": 200
},
"afterReturn": {
"nodeCount": 84,
"selectedCount": 0,
"historyLength": 2,
"commands": {
"plugin:event|listen": 26,
"get_app_info_command": 1,
"list_image_models_command": 1,
"list_video_models_command": 1,
"get_task_queue_command": 1,
"estimate_image_cost_command": 5,
"storyteller_get_login_session_command": 1,
"plugin:http|fetch": 5,
"storyteller_get_credits_command": 1,
"storyteller_get_subscription_command": 1,
"plugin:window|is_maximized": 1,
"plugin:event|unlisten": 2
}
}
}
],
"errors": []
}
+80
View File
@@ -0,0 +1,80 @@
# Website login from the desktop app
The React login/signup dialog calls Tauri commands only, including its startup
session check and password forms. Website and QR actions appear only in login
mode. Tauri never connects to MySQL; the desktop uses Rust HTTP API bindings. Creating/polling login challenges and
verifying `/v1/session` use `artcraft_client::endpoints::users::login_challenges`.
The Rust command uses `AppEnvConfigs.storyteller_host` for every request, keeps the
private device token in native memory, and returns only a local challenge handle,
approval URL/code/deadline, status, and the verified user to JavaScript. The signed
session cookie never crosses IPC or gets written to JavaScript local storage.
After redemption, Rust verifies the signed session against the same API host,
installs the server's cookie into the native HTTP jar and credential manager, and
persists the jar. Closing the dialog removes the local handle; a cancellation
while HTTP is in flight prevents installing the late response. Retries retrieve
the same server session. Neither creation nor website approval pre-creates one.
## Session response compatibility
A server feature flag added after a desktop release must not prevent login. The
session decoder keeps known feature flags and ignores unknown flag names, such
as `use_qt` on older clients. Malformed flag values and required authentication
fields still fail validation. Tests cover a real Rust HTTP session response with
both known and future flags, followed by native cookie installation/persistence.
A late startup session check cannot reset an active or completed website login.
## Diagnosing 401 responses
`storyteller_activity_thread` reports errors from `/v1/analytics/active_user_v2`.
This is the background activity ping, not the login-challenge endpoint. It requires
a user session. An anonymous `visitor` cookie alone is now skipped. Expired or
revoked sessions can still produce a 401 and require signing in again.
Activity failures log the configured API origin (including port), endpoint, and
error. Native website-login requests/failures log origin, endpoint, and HTTP
status. Request bodies, device tokens, approval URLs, and signed cookies are not
logged by the new client, including at debug level.
## API environment
`~/Artcraft/settings/env_configs.json` controls the native API host:
```json
{"storyteller_host":"localhost","storyteller_port":12345}
```
Local mode uses `http://localhost:12345` and a local approval website at
`http://localhost:4201/login/desktop`. Run storyteller-web with its migrations
applied, and the services webapp with `USE_LOCAL_API=1`. The local website must
also use the local API. A challenge in a local database cannot be approved on the
production website, even when that browser already has a production session.
For the production API and production website, use `"storyteller_host":"production"`
(or omit the override), then restart ArtCraft. API host configuration is not
silently changed by login. Local and production approval URLs cannot be mixed.
Localhost development URLs are for the same computer; mobile QR testing needs
a shared/deployed environment.
## Tests
From the desktop repository:
```sh
SQLX_OFFLINE=true cargo test --offline -p artcraft --lib login_bridge_tests
cd frontend
npm exec vitest -- run --config libs/components/login-modal/vite.config.ts src/lib/DesktopLoginBridge.spec.tsx src/lib/login-modal.spec.tsx
```
Native tests use only ephemeral loopback HTTP servers and temporary cookie stores.
They exercise the real API bindings and the command implementation, including
cookie verification/persistence, rejection, expiry, retries, in-flight cancellation,
origin separation, no redirects, redacted errors, visitor-only telemetry, future
feature flags, malformed session data, and password login/signup/session checks.
UI tests prohibit JavaScript HTTP and runtime API imports. They verify the IPC
contract, native browser opening, QR/code display, status, backoff, cleanup,
login-only button placement, native password forms, and stale session responses.
The services repository also has real MySQL integration tests for approval,
redemption, rejection, concurrency, and downstream authentication, using isolated
local databases. No test contacts a production API or database.
@@ -10,8 +10,7 @@
// siblings, never wrapped by EngineProvider, so they have no
// dependency on the 3D engine being present.
import { useEffect, useState } from "react";
import * as gpu from "detect-gpu";
import { lazy, Suspense, useEffect } from "react";
import { useSignals } from "@preact/signals-react/runtime";
import { TopBar } from "~/components";
@@ -58,26 +57,55 @@ import { useTextToImageStore } from "./PageImage/TextToImageStore";
import { AppsIndexPage } from "./PageApps/AppsIndexPage";
import PageDraw from "./PageDraw/PageDraw";
import TextToImage from "./PageImage/TextToImage";
import ImageToVideo from "./PageVideo/ImageToVideo";
import CreateAudio from "./PageAudio/CreateAudio";
import { VideoFrameExtractor } from "./PageVideoFrameExtractor";
import { VideoWatermarkRemover } from "./PageVideoWatermarkRemover";
import { ImageWatermarkRemover } from "./PageImageWatermarkRemover";
import { ImageTo3DObject } from "./PageImageTo3DObject";
import { ImageTo3DWorld } from "./PageImageTo3DWorld";
import { RemoveBackground } from "./PageRemoveBackground";
import { Angles } from "./PageAngles";
import { Storyboard } from "./PageStoryboard";
import { PageBackgroundChange } from "./PageBackgroundChange";
import { PageScene } from "./PageScene";
import { PageVideoEditor } from "./PageVideoEditor";
import { PageMoodboard } from "./PageMoodboard";
import {
topNavMediaId,
topNavMediaUrl,
} from "~/components/signaled/TopBar/TopBar";
// Load editors when selected, keeping their code off the launch path. Drawing
// stays eager: its shared store/UI is already needed by the shell, so deferring
// its small host adapter adds first-open latency for very little byte saving.
// Declare lazy components once so navigating does not recreate their identity.
const TextToImage = lazy(() => import("./PageImage/TextToImage"));
const ImageToVideo = lazy(() => import("./PageVideo/ImageToVideo"));
const CreateAudio = lazy(() => import("./PageAudio/CreateAudio"));
const VideoFrameExtractor = lazy(() =>
import("./PageVideoFrameExtractor").then((m) => ({ default: m.VideoFrameExtractor })),
);
const VideoWatermarkRemover = lazy(() =>
import("./PageVideoWatermarkRemover").then((m) => ({ default: m.VideoWatermarkRemover })),
);
const ImageWatermarkRemover = lazy(() =>
import("./PageImageWatermarkRemover").then((m) => ({ default: m.ImageWatermarkRemover })),
);
const ImageTo3DObject = lazy(() =>
import("./PageImageTo3DObject").then((m) => ({ default: m.ImageTo3DObject })),
);
const ImageTo3DWorld = lazy(() =>
import("./PageImageTo3DWorld").then((m) => ({ default: m.ImageTo3DWorld })),
);
const RemoveBackground = lazy(() =>
import("./PageRemoveBackground").then((m) => ({ default: m.RemoveBackground })),
);
const Angles = lazy(() =>
import("./PageAngles").then((m) => ({ default: m.Angles })),
);
const Storyboard = lazy(() =>
import("./PageStoryboard").then((m) => ({ default: m.Storyboard })),
);
const PageBackgroundChange = lazy(() =>
import("./PageBackgroundChange").then((m) => ({ default: m.PageBackgroundChange })),
);
const PageScene = lazy(() =>
import("./PageScene").then((m) => ({ default: m.PageScene })),
);
const PageVideoEditor = lazy(() =>
import("./PageVideoEditor").then((m) => ({ default: m.PageVideoEditor })),
);
const PageMoodboard = lazy(() =>
import("./PageMoodboard").then((m) => ({ default: m.PageMoodboard })),
);
interface Props {
sceneToken?: string;
}
@@ -121,8 +149,7 @@ export const MainApp = ({ sceneToken }: Props) => {
toast.error("File deleted.");
});
// Session probe (runs once per shell mount) and GPU detection.
// Both are app-wide concerns, not 3D-only.
// Session probe (runs once per shell mount).
useEffect(() => {
const usersApi = new UsersApi();
usersApi.GetSession().then((result) => {
@@ -132,20 +159,6 @@ export const MainApp = ({ sceneToken }: Props) => {
});
}, []);
const [, setValidGpu] = useState("unknown");
useEffect(() => {
const { getGPUTier } = gpu;
getGPUTier().then((gpuTier) => {
console.log("GPU tier", gpuTier);
let isValid = false;
const fps = gpuTier.fps || 0;
if (gpuTier.tier > 1) isValid = true;
if (fps > 15) isValid = true;
if (gpuTier.gpu === "apple gpu (Apple GPU)") isValid = true;
setValidGpu(isValid ? "valid" : "error");
});
}, []);
const { triggerRecheck } = useLoginModalStore();
const { isOpen: isCreditsOpen, closeModal: closeCreditsModal } =
useCreditsModalStore();
@@ -179,7 +192,18 @@ export const MainApp = ({ sceneToken }: Props) => {
}}
/>
<TabBody sceneToken={sceneToken} />
<Suspense
fallback={
<div
role="status"
className="flex h-[calc(100vh-56px)] items-center justify-center text-base-fg/60"
>
Loading…
</div>
}
>
<TabBody sceneToken={sceneToken} />
</Suspense>
<GalleryDragComponent />
<ErrorDialog />
@@ -210,7 +234,7 @@ export const MainApp = ({ sceneToken }: Props) => {
};
const TabBody = ({ sceneToken }: { sceneToken?: string }) => {
const tabStore = useTabStore();
const activeTabId = useTabStore((s) => s.activeTabId);
const storyboardPageEnabled = useStoryboardPageEnabled();
// The 3D case stays unwrapped because Stage3DBody (lib) already
@@ -218,7 +242,7 @@ const TabBody = ({ sceneToken }: { sceneToken?: string }) => {
// top-level children may use position: fixed (e.g. PageDraw); the
// wrapping <div> scopes them so they don't stack as siblings of
// the TopBar at the MainApp root.
switch (tabStore.activeTabId) {
switch (activeTabId) {
case "3D":
return <PageScene sceneToken={sceneToken} />;
case "APPS":
@@ -0,0 +1,84 @@
import { beforeEach, expect, it } from "vitest";
import { useTabStore } from "./TabState";
import { useSceneStore } from "../../../../../../libs/components/pagedraw/src/lib/stores/SceneState";
import { Node } from "../../../../../../libs/components/pagedraw/src/lib/Node";
const initialScene = useSceneStore.getInitialState();
const initialTabs = useTabStore.getInitialState();
beforeEach(() => {
useSceneStore.setState(initialScene, true);
useTabStore.setState(initialTabs, true);
});
it("retains decoded images, selection, model metadata, and history across tab changes", async () => {
const image = new Node({
id: "image", type: "image", x: 10, y: 20, width: 512, height: 512,
fill: "transparent", imageFile: new File(["pixels"], "artwork.png"),
imageElement: new Image(), modelUrl: "blob:model",
});
useSceneStore.setState({ drawNodes: [image], selectedNodeIds: [image.id] });
useSceneStore.getState().saveState();
const before = useSceneStore.getState();
await useTabStore.getState().setActiveTab("2D");
await useTabStore.getState().setActiveTab("APPS");
await useTabStore.getState().setActiveTab("2D");
const after = useSceneStore.getState();
expect(after.drawNodes).toBe(before.drawNodes);
expect(after.drawNodes[0]).toBe(image);
expect(after.selectedNodeIds).toEqual([image.id]);
expect(after.history).toBe(before.history);
expect(after.historyIndex).toBe(before.historyIndex);
});
it("keeps redo available when navigating away after undo", async () => {
const scene = useSceneStore.getState();
useSceneStore.setState({ drawNodes: [rectangle("first")] });
scene.saveState();
useSceneStore.setState({ drawNodes: [rectangle("first"), rectangle("second")] });
scene.saveState();
await scene.undo();
await useTabStore.getState().setActiveTab("2D");
await useTabStore.getState().setActiveTab("IMAGE");
await useTabStore.getState().setActiveTab("2D");
await useSceneStore.getState().redo();
expect(useSceneStore.getState().drawNodes.map((node) => node.id)).toEqual(["first", "second"]);
});
it("keeps edits made while away and does not restore an old scene after a gallery reset", async () => {
useSceneStore.setState({ drawNodes: [rectangle("old")] });
await useTabStore.getState().setActiveTab("2D");
await useTabStore.getState().setActiveTab("APPS");
useSceneStore.getState().RESET();
useSceneStore.setState({ drawNodes: [rectangle("new")] });
await useTabStore.getState().setActiveTab("2D");
expect(useSceneStore.getState().drawNodes.map((node) => node.id)).toEqual(["new"]);
});
it("honors the latest rapid navigation request", async () => {
await useTabStore.getState().setActiveTab("2D");
const results = await Promise.all([
useTabStore.getState().setActiveTab("IMAGE"),
useTabStore.getState().setActiveTab("VIDEO"),
]);
expect(results).toEqual([true, true]);
expect(useTabStore.getState().activeTabId).toBe("VIDEO");
});
it("still exports a portable scene when explicitly requested", async () => {
const image = new Node({
id: "image", type: "image", x: 0, y: 0, width: 1, height: 1,
fill: "transparent", imageFile: new File(["pixels"], "image.png", { type: "image/png" }),
});
useSceneStore.setState({ drawNodes: [image] });
const exported = JSON.parse(await useSceneStore.getState().exportSceneAsJson());
expect(exported.drawNodes[0].imageDataUrl).toBe("data:image/png;base64,cGl4ZWxz");
});
function rectangle(id: string) {
return new Node({ id, type: "rectangle", x: 0, y: 0, width: 100, height: 100, fill: "red" });
}
@@ -1,5 +1,4 @@
import { create } from "zustand";
import { useSceneStore } from "@storyteller/ui-pagedraw";
export type TabId =
| "2D"
@@ -42,40 +41,14 @@ export const useTabStore = create<TabState>((set, get) => ({
tabData: {},
setActiveTab: async (newTabId) => {
const currentTabId = get().activeTabId;
// Don't do anything if we're already on this tab
if (currentTabId === newTabId) return true;
if (get().activeTabId === newTabId) return true;
try {
// Save current 2D state if we're leaving 2D tab
if (currentTabId === "2D") {
const sceneStore = useSceneStore.getState();
const sceneState = await sceneStore.serializeSceneToString();
set((state) => ({
tabData: {
...state.tabData,
"2D": sceneState,
},
}));
}
// Load 2D state if we're entering 2D tab
if (newTabId === "2D") {
const savedState = get().tabData["2D"];
if (savedState) {
const sceneStore = useSceneStore.getState();
sceneStore.loadSceneFromString(savedState);
}
}
// Update active tab
set({ activeTabId: newTabId });
return true;
} catch (error) {
console.error("Error during tab change:", error);
return false;
}
// The drawing store outlives its React page. Keep its live image objects,
// selection, and undo history when switching tabs. Export/import is for
// files; round-tripping here reread every image and rebuilt the scene.
set({ activeTabId: newTabId });
return true;
},
updateTabData: (tabId, data) => {
+46
View File
@@ -79,6 +79,7 @@
"@nx/vite": "21.2.3",
"@nx/web": "21.2.3",
"@nx/workspace": "21.2.3",
"@playwright/test": "1.63.0",
"@remix-run/node": "^2.16.5",
"@swc-node/register": "~1.9.1",
"@swc/core": "~1.5.7",
@@ -5602,6 +5603,22 @@
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -19977,6 +19994,35 @@
"dev": true,
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/portfinder": {
"version": "1.0.38",
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz",
+4 -1
View File
@@ -2,7 +2,9 @@
"name": "@frontend/source",
"version": "0.0.0",
"license": "MIT",
"scripts": {},
"scripts": {
"perf:desktop": "node tools/performance/run.mjs"
},
"private": true,
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -68,6 +70,7 @@
"@nx/vite": "21.2.3",
"@nx/web": "21.2.3",
"@nx/workspace": "21.2.3",
"@playwright/test": "1.63.0",
"@remix-run/node": "^2.16.5",
"@swc-node/register": "~1.9.1",
"@swc/core": "~1.5.7",
@@ -0,0 +1,54 @@
import { execFileSync } from "node:child_process";
import { relative } from "node:path";
// Benchmark-only instrumentation. The normal desktop build never uses this plugin.
export function benchmarkPlugin({ baselineRef, frontend } = {}) {
// Reproduce this experiment without checking out over local work. This is a
// two-module overlay, not a build of an entire historical repository.
const baseline = new Map();
if (baselineRef) {
for (const path of ["pages/MainApp.tsx", "pages/Stores/TabState.ts"]) {
baseline.set(`/apps/artcraft/app/src/${path}`, execFileSync("git", ["show", `${baselineRef}:frontend/apps/artcraft/app/src/${path}`], { cwd: frontend, encoding: "utf8" }));
}
}
const exports = new Map([
["/pages/Stores/TabState.ts", ["tabs", "useTabStore"]],
["/pagedraw/src/lib/stores/SceneState.ts", ["scene", "useSceneStore"]],
["/pagedraw/src/lib/Node.ts", ["Node", "Node"]],
]);
return {
name: "artcraft-performance-fixtures",
enforce: "pre",
transform(code, id) {
const source = [...baseline].find(([suffix]) => id.endsWith(suffix));
if (source) code = source[1];
for (const [suffix, [key, value]] of exports) {
if (id.endsWith(suffix)) {
return `${code}\nwindow.__ARTCRAFT_BENCH__ ??= {};\nwindow.__ARTCRAFT_BENCH__.${key} = ${value};\n`;
}
}
if (source) return code;
},
generateBundle: {
order: "post",
handler(_options, bundle) {
const chunks = Object.values(bundle).filter((item) => item.type === "chunk");
this.emitFile({
type: "asset",
fileName: "performance-bundle.json",
source: JSON.stringify(chunks.map((chunk) => ({
file: chunk.fileName,
entry: chunk.isEntry,
dynamicEntry: chunk.isDynamicEntry,
bytes: Buffer.byteLength(chunk.code),
imports: chunk.imports,
largestModules: Object.entries(chunk.modules)
.sort((a, b) => b[1].renderedLength - a[1].renderedLength)
.slice(0, 25)
.map(([id, module]) => ({ module: relative(frontend, id), renderedBytes: module.renderedLength })),
})), null, 2),
});
},
},
};
}
@@ -0,0 +1,103 @@
// Runs before any application script. No real account, Rust IPC, or remote API.
export function installBrowserFixture() {
const counters = { commands: {}, fileReads: 0, webglContexts: 0, longTasks: [] };
window.__ARTCRAFT_COUNTERS__ = counters;
const getContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (type, ...args) {
if (type === "webgl" || type === "webgl2" || type === "experimental-webgl") counters.webglContexts++;
return getContext.call(this, type, ...args);
};
const readAsDataURL = FileReader.prototype.readAsDataURL;
FileReader.prototype.readAsDataURL = function (...args) {
counters.fileReads++;
return readAsDataURL.apply(this, args);
};
if (PerformanceObserver.supportedEntryTypes.includes("longtask")) {
new PerformanceObserver((list) => {
counters.longTasks.push(...list.getEntries().map(({ startTime, duration }) => ({ startTime, duration })));
}).observe({ type: "longtask", buffered: true });
}
let callbackId = 0;
const callbacks = new Map();
window.__TAURI_INTERNALS__ = {
metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main" } },
transformCallback(callback) { callbacks.set(++callbackId, callback); return callbackId; },
unregisterCallback(id) { callbacks.delete(id); },
convertFileSrc(path) { return path; },
async invoke(command, args) {
counters.commands[command] = (counters.commands[command] ?? 0) + 1;
if (command === "plugin:event|listen") return args.handler;
if (command === "plugin:window|inner_size") return { width: 1440, height: 1000 };
if (command === "plugin:window|scale_factor") return 1;
if (command === "storyteller_get_login_session_command") return {
username: "performance_fixture", user_token: "u_performance_fixture",
};
if (command === "get_task_queue_command") return { payload: { tasks: [] } };
// Use the bundled model overlay, as the app does when the listing is unavailable.
if (command === "list_image_models_command" || command === "list_video_models_command") throw new Error("Offline model listing fixture");
if (command === "plugin:app|version") return "0.0.0-benchmark";
if (command === "plugin:app|name") return "ArtCraft Benchmark";
if (command === "plugin:os|platform") return "macos";
if (command.startsWith("plugin:http|")) throw new Error("Network disabled in performance fixture");
return { success: true, payload: {} };
},
};
window.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener() {} };
}
export async function seedDrawingScene() {
const { scene, Node } = window.__ARTCRAFT_BENCH__;
scene.getState().RESET();
scene.getState().setBaseImageInfo({ url: "", mediaToken: "fixture-blank", isBlankCanvas: true, blankCanvasWidth: 1024, blankCanvasHeight: 1024 });
// Deterministic noisy pixels resist PNG compression, like imported artwork.
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 1024;
const context = canvas.getContext("2d");
const pixels = context.createImageData(1024, 1024);
let random = 12345;
for (let i = 0; i < pixels.data.length; i += 4) {
random = (Math.imul(random, 1664525) + 1013904223) >>> 0;
pixels.data[i] = random & 255;
pixels.data[i + 1] = (random >>> 8) & 255;
pixels.data[i + 2] = (random >>> 16) & 255;
pixels.data[i + 3] = 255;
}
context.putImageData(pixels, 0, 0);
const blob = await new Promise((resolve) => canvas.toBlob(resolve));
const drawNodes = [];
for (let i = 0; i < 4; i++) {
const node = new Node({ id: `fixture-image-${i}`, type: "image", x: i * 220, y: i * 100, width: 400, height: 400, fill: "transparent" });
await node.setImageFromFile(new File([blob], `fixture-${i}.png`, { type: "image/png" }));
drawNodes.push(node);
}
for (let i = 0; i < 80; i++) {
drawNodes.push({
id: `fixture-line-${i}`, type: "line", draggable: true, stroke: "#7766ee", strokeWidth: 3,
points: Array.from({ length: 200 }, (_, j) => j % 2 === 0 ? j * 4 : i * 10 + Math.sin(j) * 30),
});
}
scene.setState({ drawNodes, selectedNodeIds: [drawNodes[0].id] });
scene.getState().saveState();
return { images: 4, imageSize: 1024, imageBytesEach: blob.size, strokes: 80, coordinatesPerStroke: 200 };
}
export async function measureTabChange(target) {
const counters = window.__ARTCRAFT_COUNTERS__;
const readsBefore = counters.fileReads;
const start = performance.now();
const success = await window.__ARTCRAFT_BENCH__.tabs.getState().setActiveTab(target);
if (!success) throw new Error(`Tab change to ${target} failed`);
const stateReadyMs = performance.now() - start;
const ready = () => target === "2D"
? !!document.querySelector(".konvajs-content canvas")
: !!Array.from(document.querySelectorAll("h1")).find((h) => h.textContent.includes("craft"));
while (!ready()) {
if (performance.now() - start > 15000) throw new Error(`Tab ${target} never rendered`);
await new Promise(requestAnimationFrame);
}
// A rendering opportunity after the target DOM committed, not GPU completion.
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const paintMs = performance.now() - start;
performance.measure(`artcraft:tab:${target}`, { start, end: performance.now() });
return { stateReadyMs, paintMs, fileReads: counters.fileReads - readsBefore };
}
+33
View File
@@ -0,0 +1,33 @@
import { readFile } from "node:fs/promises";
const [beforePath, afterPath] = process.argv.slice(2);
if (!beforePath || !afterPath) throw new Error("Usage: node tools/performance/compare.mjs before.json after.json");
const before = JSON.parse(await readFile(beforePath, "utf8"));
const after = JSON.parse(await readFile(afterPath, "utf8"));
for (const field of ["platform", "arch", "cpu", "browser", "viewport", "ipc", "network", "runs"]) {
if (before.environment[field] !== after.environment[field]) throw new Error(`Different ${field}; do not compare these runs`);
}
if (before.errors.length || after.errors.length) throw new Error("A run contains browser errors");
if (JSON.stringify(before.samples[0].fixture) !== JSON.stringify(after.samples[0].fixture)) throw new Error("Different scene fixtures");
const rows = [["Measurement", "Before", "After", "Reduction"]];
for (const [label, event, metric, unit, scale] of [
["Startup to frame", "startup", "readyMs", "ms", 1],
["Refresh to frame", "refresh", "readyMs", "ms", 1],
["Startup JavaScript", "startup", "jsBytes", "MB", 1e6],
["Startup long tasks (sum)", "startup", "longTaskMs", "ms", 1],
["First drawing page to frame", "firstDraw", "paintMs", "ms", 1],
["Drawing to home: dispatch", "leaveDraw", "stateReadyMs", "ms", 1],
["Drawing to home: frame", "leaveDraw", "paintMs", "ms", 1],
["Home to drawing: dispatch", "returnDraw", "stateReadyMs", "ms", 1],
["Home to drawing: frame", "returnDraw", "paintMs", "ms", 1],
]) {
const a = before.summary[event][metric].median;
const b = after.summary[event][metric].median;
rows.push([label, `${(a / scale).toFixed(2)} ${unit}`, `${(b / scale).toFixed(2)} ${unit}`, `${((a - b) / a * 100).toFixed(1)}%`]);
}
const widths = rows[0].map((_, col) => Math.max(...rows.map((row) => row[col].length)));
const format = (row) => `| ${row.map((cell, col) => cell.padEnd(widths[col])).join(" | ")} |`;
console.log(format(rows[0]));
console.log(`|-${widths.map((w) => "-".repeat(w)).join("-|-")}-|`);
for (const row of rows.slice(1)) console.log(format(row));
+125
View File
@@ -0,0 +1,125 @@
import { build, preview } from "vite";
import { chromium } from "@playwright/test";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import os from "node:os";
import { execFileSync } from "node:child_process";
import { parseArgs } from "node:util";
import { benchmarkPlugin } from "./benchmark-plugin.mjs";
import { installBrowserFixture, seedDrawingScene, measureTabChange } from "./browser-fixture.mjs";
const frontend = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const { values } = parseArgs({ options: {
output: { type: "string", default: process.env.ARTCRAFT_PERF_DIR || "/tmp/artcraft-performance" },
runs: { type: "string", default: process.env.ARTCRAFT_PERF_RUNS || "7" },
"skip-build": { type: "boolean", default: !!process.env.ARTCRAFT_PERF_SKIP_BUILD },
"build-only": { type: "boolean", default: false },
channel: { type: "string", default: "chrome" },
"baseline-ref": { type: "string" },
"smoke-imports": { type: "boolean", default: false },
} });
const output = resolve(values.output);
const runs = Number(values.runs);
if (!Number.isInteger(runs) || runs < 1) throw new Error("--runs must be a positive integer");
const configFile = resolve(frontend, "apps/artcraft/vite.config.ts");
// Match `npm run build` in the app: Tailwind resolves its config/content from cwd.
process.chdir(dirname(configFile));
await mkdir(output, { recursive: true });
if (!values["skip-build"]) {
await build({ configFile, plugins: [benchmarkPlugin({ baselineRef: values["baseline-ref"], frontend })], build: { outDir: resolve(output, "dist"), manifest: true } });
await writeFile(resolve(output, "build-info.json"), JSON.stringify({
builtAt: new Date().toISOString(),
revision: execFileSync("git", ["rev-parse", "HEAD"], { cwd: frontend, encoding: "utf8" }).trim(),
baselineOverlay: values["baseline-ref"] || null,
changedFiles: execFileSync("git", ["diff", "--name-only"], { cwd: frontend, encoding: "utf8" }).trim().split("\n"),
}, null, 2));
}
if (values["build-only"]) process.exit(0);
const server = await preview({ configFile, build: { outDir: resolve(output, "dist") }, preview: { host: "127.0.0.1", port: 0, strictPort: false } });
let browser;
try {
browser = await chromium.launch({ channel: values.channel, headless: true });
const url = server.resolvedUrls.local[0];
const samples = [];
const errors = [];
const importedPages = [];
for (let run = 0; run < runs; run++) {
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 });
await context.addInitScript(installBrowserFixture);
const page = await context.newPage();
page.on("pageerror", (error) => { errors.push(error.message); console.error("Browser error:", error.message); });
// No analytics, external media, or authenticated API traffic in this fixture.
await context.route("**/*", (route) => route.request().url().startsWith(url) || route.request().url().startsWith("blob:")
? route.continue() : route.abort());
const cdp = await context.newCDPSession(page);
await cdp.send("Network.enable");
await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
await page.goto(url, { waitUntil: "domcontentloaded" });
await page.getByRole("heading", { name: "What will you craft today?" }).waitFor();
await page.waitForFunction(() => !document.querySelector("video"));
if (!await page.evaluate(() => getComputedStyle(document.querySelector(".fixed")).position === "fixed")) {
throw new Error("Tailwind styles are missing; benchmark layout is invalid");
}
const collectStartup = async () => page.evaluate(async () => {
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
return {
readyMs: performance.now(),
jsBytes: performance.getEntriesByType("resource").filter((r) => r.name.endsWith(".js")).reduce((sum, r) => sum + r.decodedBodySize, 0),
longTaskMs: window.__ARTCRAFT_COUNTERS__.longTasks.reduce((sum, t) => sum + t.duration, 0),
webglContexts: window.__ARTCRAFT_COUNTERS__.webglContexts,
};
});
const startup = await collectStartup();
await page.reload({ waitUntil: "domcontentloaded" });
await page.getByRole("heading", { name: "What will you craft today?" }).waitFor();
await page.waitForFunction(() => !document.querySelector("video"));
const refresh = await collectStartup();
const fixture = await page.evaluate(seedDrawingScene);
const firstDraw = await page.evaluate(measureTabChange, "2D");
// Allow image decoding and the canvas bake debounce to settle outside timing.
await page.waitForTimeout(1000);
const leaveDraw = await page.evaluate(measureTabChange, "APPS");
const returnDraw = await page.evaluate(measureTabChange, "2D");
await page.waitForTimeout(1000);
const afterReturn = await page.evaluate(() => ({
nodeCount: window.__ARTCRAFT_BENCH__.scene.getState().drawNodes.length,
selectedCount: window.__ARTCRAFT_BENCH__.scene.getState().selectedNodeIds.length,
historyLength: window.__ARTCRAFT_BENCH__.scene.getState().history.length,
commands: window.__ARTCRAFT_COUNTERS__.commands,
}));
if (run === 0) await page.screenshot({ path: resolve(output, "drawing.png") });
samples.push({ startup, refresh, firstDraw, leaveDraw, returnDraw, fixture, afterReturn });
console.log(JSON.stringify({ run: run + 1, startup, refresh, firstDraw, leaveDraw, returnDraw }));
// Validate deferred module evaluation separately from all timed operations.
if (values["smoke-imports"] && run === runs - 1) {
const manifest = JSON.parse(await readFile(resolve(output, "dist/.vite/manifest.json"), "utf8"));
for (const [source, chunk] of Object.entries(manifest)) {
if (source.startsWith("src/pages/") && chunk.isDynamicEntry) {
await page.evaluate(async (file) => { await import(/* @vite-ignore */ `/${file}`); }, chunk.file);
importedPages.push(source);
}
}
}
await context.close();
}
const summary = {};
for (const event of ["startup", "refresh", "firstDraw", "leaveDraw", "returnDraw"]) {
summary[event] = {};
for (const metric of Object.keys(samples[0][event])) {
const values = samples.map((s) => s[event][metric]).sort((a, b) => a - b);
summary[event][metric] = { median: values[Math.floor(values.length / 2)], min: values[0], max: values.at(-1) };
}
}
const result = {
timestamp: new Date().toISOString(), build: JSON.parse(await readFile(resolve(output, "build-info.json"), "utf8")),
environment: { platform: os.platform(), arch: os.arch(), cpu: os.cpus()[0].model, browser: browser.version(), node: process.version, runs, viewport: "1440x1000@1", ipc: "mocked", network: "local assets only, browser cache disabled" },
summary, samples, importedPages, errors: [...new Set(errors)],
};
await writeFile(resolve(output, "results.json"), `${JSON.stringify(result, null, 2)}\n`);
console.log(JSON.stringify({ summary, errors: result.errors, output }, null, 2));
if (errors.length) throw new Error("Browser errors invalidate this run; see results.json");
} finally {
await browser?.close();
await new Promise((resolve, reject) => server.httpServer.close((error) => error ? reject(error) : resolve()));
}