mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
Compare commits
1
Commits
8abde75133
...
bt-perf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84519e0e68 |
@@ -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.
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Generated
+46
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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()));
|
||||
}
|
||||
Reference in New Issue
Block a user