Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor 55e6e5cabb frontend: Sonar follow-ups (S8786, S6852, S7059) (#2824)
Two issues SonarCloud raised on main after #2821/#2822 merged:

- **typescript:S8786** `SceneState.ts` `base64ToFile`: #2821's
`/:([^;]*);/` is still flagged. The mime type is now read with
`indexOf`: the text between the first `:` and the `;` after it, else
`image/png`. Checked equal to the regex on `data:image/png;base64`,
`data:;base64`, a header with no `:` or `;`, and several `:`.
- **typescript:S6852** `MentionTextarea`: the `role="textbox"` editor
gets `tabIndex={disabled ? -1 : 0}`. A `contentEditable` element was
focusable already, so nothing changes for users.

Typechecked locally against main (no new errors).

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved image import handling so MIME types are identified more
reliably, with PNG used as a fallback when the type cannot be
determined.
* Disabled text editors are no longer reachable through sequential
keyboard navigation; enabled editors remain tabbable.
* Physics support is initialized during editor setup after the required
canvas and container elements are available.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
- **typescript:S7059** `editor.ts`: `ensureAmmoLoaded()` (an async load)
moves out of the `Editor` constructor to `EngineProvider`, the only
place an `Editor` is built, right before `new Editor(...)`. The loader
is a singleton, so the script is still appended once.
2026-11-10 22:51:32 -05:00
Dylan M. Taylor 7c8419fabd frontend: Number.parseFloat/parseInt/isFinite/isNaN (Sonar S7773) (#2819)
Clears the 82 open typescript:S7773 issues (35 files), only on the lines
Sonar flagged.

- `parseFloat` / `parseInt` → `Number.parseFloat` / `Number.parseInt`:
the same functions (`Number.parseFloat === parseFloat`).
- `isFinite` / `isNaN` → `Number.isFinite` / `Number.isNaN`: they differ
only for arguments that aren't numbers. TypeScript's lib types the
globals as taking `number`, and each call already passes one (`maxLen`,
`Number(value)`, a parsed count), so nothing changes. Typecheck is left
to CI.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Improvements**
* Video frame extraction and watermark-removal pages now provide
consistent playback controls, including seeking, playback, mute, and
volume adjustment.
* Prompt-length indicators and validation consistently reflect
configured limits, including unlimited limits.
* **New Features**
* Prompt fields display a character counter that highlights text
exceeding the configured limit.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Also: rebased on main, and the length counter and over-limit check that
the five prompt boxes repeated are now `PromptLengthCounter` /
`isOverPromptLimit` (`promptbox/src/lib/PromptLengthCounter.tsx`). That
removes the duplication the new-code gate counted against this PR.
2026-11-10 22:40:36 -05:00
FredAppsandDylan M. Taylor 130bfd71d6 libs/components: mark fire-and-forget promises with void (Sonar S9383) (#2807)
## Summary

Resolves all 66 SonarCloud **typescript:S9383** findings ("Promises must
be awaited, end with a call to .catch, end with a call to .then with a
rejection handler or be explicitly marked as ignored with the `void`
operator") in `frontend/libs/components`, across 41 files:

| Library                | Findings |
|------------------------|----------|
| `upload-modal`         | 14       |
| `promptbox`            | 11       |
| `settings-modal`       | 11       |
| `pagescene`            | 10       |
| `video-editor`         | 7        |
| `lightbox-modal`       | 3        |
| `pagedraw`             | 3        |
| `generation-list`      | 2        |
| `help-menu`            | 2        |
| `gallery-modal`        | 1        |
| `pricing-modal`        | 1        |
| `provider-setup-modal` | 1        |

The flagged expressions include settings and session loaders
(`fetchData()`, `fetchSession()`) called from effects, upload starts and
retries in the upload modals, enqueue handlers in the prompt boxes,
`audio.play()` and `el.play()` previews, scene render and load calls in
`pagescene`, bookmark actions in `video-editor`, and async IIFEs.

This is part 3 of 3 for artcraft's 157 S9383 findings. Part 1 (Tauri
libraries) is #2805, and part 2 (`apps/artcraft/app`) is #2806.

Some of these files are also changed by open PRs for unrelated reasons,
for example #2615 (settings panes), #2519, #2520 and #2083. Each change
here inserts one word at the start of a statement, so a rebase either
way is trivial.

## Approach

This ports the approach of storytold/artcraft-services#179, which fixes
the same rule in artcraft-services. **#179 is still open and unmerged.**
This PR follows its approach and does not depend on it.

- Each flagged expression is a fire-and-forget promise: a listener
`setup()`, an `unlisten.then(f => f())` cleanup, a loader called from an
effect, an upload kicked off from a handler, or an async IIFE. Prefixing
it with `void` marks the ignored promise explicitly, which is the fix
the rule documents.
- It deliberately does not add `.catch()` everywhere. These call sites
already handle failures inside the async function or surface them
through their own state, and empty handlers would hide errors.
- Async IIFEs become `void (async () => …)()`. `if (cond) call()`
becomes `if (cond) void call()`.
- There is no behaviour change. `void` only discards the promise value,
which was already being discarded.

Each insertion is placed at the exact `textRange` start offset of the
SonarCloud finding, with no other edits.

## Testing

- Each changed file parses with `esbuild <file>`, using the same check
as #179.
- I parsed each changed file with the TypeScript 5.9 compiler API
(`createSourceFile`). That found no parse errors, and every inserted
`void` is either a whole expression statement or an arrow-function body
applied to a call, never part of a larger expression.
- `frontend/.eslintrc.json` has no `no-void` rule.
- I did not run the full Nx type-check, lint or test targets, because
`frontend/node_modules` is not installed in my environment. Please rely
on CI.
- No tests are added. Discarding an already-discarded promise value is
not observable, so no test can fail before this change and pass after
it.

**Added by @dylanmtaylor, to clear the new-code duplication gate.** The
`void` lines sat inside copy-pasted code:
- `ProviderAccountBlock` replaces the identical `GrokAccountBlock` and
`MidjourneyAccountBlock`.
- `UploadFilesPanels` (file list, resize handle, previous/next; each
modal passes its preview) replaces the layout the 3D, Image and Splat
upload modals repeated.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
* Multi-file uploads across image, 3D, and splat workflows now share a
two-panel layout with file statuses, preview selection, removal, retry,
and previous/next navigation.
* Grok and Midjourney account controls now use a shared interface that
displays login status and provides available connect or disconnect
actions. Failed credential checks clear the displayed session, and
account controls show a loading state while checking.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
2026-11-10 22:39:17 -05:00
Dylan M. Taylor d6772fdd94 frontend: uuidv4 for local ids instead of Math.random (Sonar S2245) (#2820)
Clears the 38 open typescript:S2245 issues (18 files).

Each `Math.random().toString(36)…` made a local id: an upload row, a
reference title, a batch or subscriber id, an idempotency token, an
image cache-buster. They now use `uuidv4()` from `uuid`, which is
already a dependency and imported the same way elsewhere (`import { v4
as uuidv4 } from "uuid"`). It reads `crypto.getRandomValues`, so it also
works where `crypto.randomUUID` doesn't (outside a secure context). The
`crypto.randomUUID ? … : Math.random()…` fallbacks therefore collapse to
`uuidv4()`, and `subscriberId ? subscriberId : …` becomes `subscriberId
|| uuidv4()`.

Ids are now 36 characters instead of 5–13. Nothing parses or compares
their length. Typecheck is left to CI.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Improvements**
* Creative tools now handle identifiers more consistently for generated
content, uploads, reference media, and generation batches.
* Completed generation results are displayed more reliably when there
isn’t a matching pending result.
* These improvements span image, video, audio, 3D, drawing, and
visual-effects workflows, helping keep related media and results
organized across the app.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Also: `ImageTo3DStore` and `ImageTo3DWorldStore` shared the same 30-line
`completeGeneration` (find the subscriber's result, else the first
pending one, and mark it completed, or add a completed one). It's now
one `completeGenerationResult` in `pages/Stores/`, which removes the
duplication the new-code gate counted against this PR.
2026-11-10 21:49:49 -05:00
Dylan M. Taylor 0c11395467 Sonar: clear the shell, workflow, Rust and HTML findings (#2818)
Clears the open SonarCloud issues outside the TypeScript frontend,
except the Rust ones, which moved to #2823 so each PR stays small.
Compile and tests are left to CI.

**Security**
- `frontend/api_docs.html` (Web:S5725): `integrity` (sha384 of the
pinned `swagger-ui-dist@5.32.0` files) and `crossorigin="anonymous"` on
the three unpkg assets, over `https:`.
- S6505: `--ignore-scripts` on `npm install` / `npm ci` in the dev and
build scripts and in the macOS and Windows publish workflows.
`artcraft-linux-publish.yml` already builds with `npm ci
--ignore-scripts`, and the frontend has no install scripts of its own.
- S7637: `dtolnay/rust-toolchain@stable` and
`tauri-apps/tauri-action@v0` pinned by commit SHA. For tauri-action
that's the commit `v0` points to (`84b9d35…`). The Linux workflow had
pinned the tag object instead, and is fixed the same way.
- S6505 / S8543: the macOS build runs `./node_modules/.bin/nx` instead
of `npx nx`, as the Linux workflow does.

**Reliability**
- shell:S7688: `[[ ]]` in place of `[` / `test` in
`frontend_preflight.sh` and the Linux packaging scripts.

**Analysis scope** (`sonar-project.properties`), each with its reason in
the file:
- Not analysed: `crates/vendor/**` (third-party), the upstream Python
reference implementations beside the Grok and Sora clients (nothing runs
them), and the hand-run developer tools `frontend/scripts/unix-dev.mjs`
and `frontend/tools/performance/**`. These held the S8564, S1244, S2245,
S4036, S5443 and S8707 findings.
- rust:S4347 not raised on `DeterministicRng`, the fixed-seed generator
for tests.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Improvements**
* File downloads and database maintenance now use asynchronous
operations, helping the app remain responsive during these tasks.
  * API documentation assets load over HTTPS with integrity checks.
* **Build and Reliability**
* Build and publishing workflows use pinned tool versions and skip
package lifecycle scripts during dependency installation.
  * Linux packaging checks use consistent shell conditionals.
* Frontend builds use the locally installed Nx command where applicable.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
- typescript:S2187 not raised on the ApiManager tests, which build their
cases with shared helpers that call `it` (`BetaKeysApi.test.ts`).
typescript:S2819 not raised on `*.worker.ts`: a dedicated worker's
messages come only from the page that started it.
2026-11-10 21:39:12 -05:00
Dylan M. Taylor 773e5925b9 Rust: tokio::fs in async functions, TaskId Display, refresh token out of source (Sonar) (#2823)
The Rust part of #2818, split out so each PR stays small. Clears the
open rust:S7493, rust:S7458 and secrets:S6418 issues. Compile and tests
are left to CI.

- rust:S7493: `tokio::fs` in place of blocking `std::fs` inside async
functions (`auto_download`, `simple_http_download`, the
fal/Sora/WorldLabs downloads, the `sqlite_tasks` reset), as in
#2803/#2804. The downloaded bytes are handed to `tokio::fs::write` owned
(no copy), per CodeRabbit on #2818. `sqlite_tasks` now depends on
workspace `tokio` with `fs` (one line in `Cargo.lock`).
- rust:S7458: `TaskId`'s inherent `to_string` is removed. `Display`
gives the same string.
- secrets:S6418: the `#[ignore]` WorldLabs refresh-token test reads
`WORLDLABS_REFRESH_TOKEN` instead of holding a token in source. ⚠️ The
token is still in git history, so its owner should revoke it.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Refactor**
* Downloaded files and task checkpoints are now handled using
asynchronous file operations across several generation workflows.
* Database-file cleanup during connection and migration is also
asynchronous; removal errors continue to be logged, and migration
proceeds.
* These changes preserve the existing download, checkpoint, and
error-handling behavior while reducing blocking file operations.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-10 21:38:23 -05:00
Dylan M. Taylor 8d6fc363b1 frontend: roles, keyboard access and label association (Sonar S6848, S6853) (#2822)
Clears the 59 open typescript:S6848 issues ("non-native interactive
element without a role").

- **Clickable things that act like buttons (16)**: upload and preview
tiles, storyboard frames, help items, reference thumbnails, remove
buttons, deck cards, asset items, popover menu items. They get
`role="button"`, `tabIndex={0}` and `onKeyDown={clickOnEnterOrSpace}`,
the pattern `GalleryRow` already uses. `clickOnEnterOrSpace` (new,
`@storyteller/common`) calls the element's own `click()`, so each
`onClick` runs unchanged. Menu items also get `aria-disabled` when
disabled (their `onClick` already ignores the click). The bulk-select
box is `role="checkbox"` with `aria-checked`. In `ImagePromptRow` the
attributes come before dnd-kit's `attributes`/`listeners` spread, so
when reordering is on, dnd-kit's own role and keyboard sensor still win.
- **Not controls (42)**: modal and menu backdrops, wrappers that only
stop event propagation, mouse-only drag/resize handles and slider
tracks, trigger wrappers around a child button, the tooltip body. They
get `role="presentation"`, which says what they already are to assistive
tech. Nothing changes for mouse users.
- **The prompt editor** (`contentEditable` in `MentionTextarea`) is
`role="textbox"` with `aria-multiline="true"`.

Mouse behaviour is unchanged. Keyboard users can now Tab to and activate
the 16 button-like elements. Typecheck is left to CI.

**typescript:S6853 (13)**: labels tied to their controls. In the
storyboard metadata editor, the signup form and the cost modal, each
label gets `htmlFor` and its control a matching `id` (`Input` and
`Select` pass `id` through, and `Select`'s goes on its `ListboxButton`).
These render once per page. The two colour-picker labels, which already
wrap their `<input type="color">`, get `sr-only` text in place of their
`title`-only name.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Accessibility**
* Added keyboard access to image tiles, asset cards, list items, and
other controls, including Enter and Space activation.
* Improved screen-reader support with clearer labels for controls and
thumbnails, and associations between form labels and fields.
* Updated accessibility semantics for decorative content and interactive
elements.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-10 20:22:41 -05:00
Dylan M. Taylor f9f4c99e4f frontend: Sonar reliability fixes (promises in conditions, async executors, identical branches, reduce, regex) (#2821)
Clears 51 open TypeScript issues. Typecheck and build are left to CI.

**typescript:S6544 (17)**
- A cached promise checked by truthiness (`if (existing)`, `if
(cached)`, `if (ammoLoadPromise)`…) is checked as `!== undefined` / `!==
null` against its map or field type. Same branches, since a Promise is
always truthy.
- `scene.ts` `load_glb_wrapped_no_cors` / `load_splat_wrapped_no_cors`:
the fetch moves out of the `async` Promise executor. The methods were
already `async`, so a failed fetch now `throw`s (rejects the same way),
and only the callback APIs (`GLTFLoader.parse`, `SplatMesh` `onLoad`)
stay inside `new Promise`.
- `SceneManagerAPI` declares the promises its implementation returns
(`create`/`retrieve` → `Promise<THREE.Object3D | undefined>`,
`update`/`delete` → `Promise<void>`). Nothing calls through the
interface.
- `convertFbxToGlb.worker.ts`: `onmessage` calls `void convert(event)`.

**typescript:S3923 / S1764 / S3516 / S905 (12)**: identical branches
collapsed (`variant === "world" ? "max-w-[1600px]" : "max-w-[1600px]"`,
`ScissorIcon : ScissorIcon`, `index % 2 === 0 ? point + offset : point +
offset`, PromptBox3D's two `setIsEnqueueing(false); return;` arms,
`{DEFAULT_GRID_CONFIG ? null : null}` and its now-unused import), `x &&
x`, the `this.gridHelper;` statement, and `CurrentUserBadge`, which
always rendered `null` (its markup was commented out).

**typescript:S6959 (5)**: each `reduce` starts from the first element.
Every one is behind an emptiness check, and comparing the first element
with itself keeps it.

**typescript:S2871 (4)**: `sort((a, b) => a.localeCompare(b))` for the
order-independent cache keys and the board and font name lists.

**typescript:S8786 (8)**
- Markdown link text is `[^\][]+`. `@image(url, size)` drops the `\s*`
before the size (it's `.trim()`ed already). ATX headings `trimStart()`
the text instead of `\s*`. Slugs trim one `-` (runs were already
collapsed to one).
- `color.ts` `stripCssNoise`: `!important` is replaced by a space, then
trailing `;` are trimmed in a loop.
- Data-URL mime: `/:([^;]*);/`.
- `ControlPanelSceneObject` number check: `/^-?\d*(\.\d*)?$/`. The old
`.` was unescaped and matched any character, so `12a3` passed as a
number.

**Others**: `window.open(…, "noopener,noreferrer")` (S5148).
`AlertTitle` renders `children` explicitly (S6850). The folder name hash
wraps with `Math.imul(…, 1)` instead of `| 0`, the same int32 result
(S7767). `autoFocus` is removed from the two rename inputs, which their
refs already focus and select (S9379).

**Not here**: S7059 in `editor.ts` (`ensureAmmoLoaded()` in the
constructor) is on a line #2807 changes.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved reliability when loading 3D scene assets and completing
drag-and-drop operations.
* Corrected numeric input validation, scene import handling, and video
duration tie handling.
* Improved media, audio, and video cache handling to avoid unnecessary
retries or missed waits.
* Refined markdown parsing for links, headings, image directives, and
heading anchors.
  * Discord invites now open with additional browser protections.
  * Improved CSS color cleanup and handling of pasted scene elements.
* **Improvements**
* Made ordering of tags, fonts, and prompt-related items more
consistent.
  * Improved video editor timeline selections.
* Removed the non-functional account badge from the video editor header.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

**typescript:S1848 (2)**: `new SplatMesh({ …, onLoad })` created an
instance only for its callback. `scene.ts` now awaits `mesh.initialized`
(Spark's promise, which resolves after load), so a failed load rejects
instead of leaving the promise pending. `viewer-3d.tsx` runs the same
body in `initialized.then`, with a `.catch` that logs.

Kept as is: `TabState.setActiveTab` still resolves `true` (S3516 stays
open), because `TabState.spec.ts` and the tab-change benchmark read its
result.
2026-11-10 20:22:36 -05:00
FredApps 71adfd4701 artcraft app: mark fire-and-forget promises with void (Sonar S9383) (#2806)
## Summary

Resolves all 38 SonarCloud **typescript:S9383** findings ("Promises must
be awaited, end with a call to .catch, end with a call to .then with a
rejection handler or be explicitly marked as ignored with the `void`
operator") in `frontend/apps/artcraft/app`, across 19 files. They are:

- startup calls in `index.tsx`, such as `SyncStorytellerApiConfig()` and
`persistLogin()`
- polling in `useActiveJobs`, `useBackgroundLoadingMedia`,
`signals/activeJobs.ts` and `signals/authentication/fetchers.ts`
- listener cleanups (`unlisten*.then(f => f())`) in `TaskQueue` and
`useDesktopGenerationFeed`
- UI handlers that start uploads or renames, such as
`ImageTo3DExperience`, `UploadFilesMedia`, `SceneTitleInput`,
`ProfileDropdown` and `DesktopGenerationGallery`
- async IIFEs in `useDesktopUsername`, `TaskQueue` and
`BaseImageSelector`

This is part 2 of 3 for artcraft's 157 S9383 findings. Part 1 (Tauri
libraries) is #2805. Part 3 (`libs/components`) is #2807.

Several of these files are also changed by open PRs for unrelated
reasons, for example #2519, #2520, #2615 and #2064. Each change here
inserts one word at the start of a statement, so a rebase either way is
trivial.

## Approach

This ports the approach of storytold/artcraft-services#179, which fixes
the same rule in artcraft-services. **#179 is still open and unmerged.**
This PR follows its approach and does not depend on it.

- Each flagged expression is a fire-and-forget promise: a listener
`setup()`, an `unlisten.then(f => f())` cleanup, a loader called from an
effect, an upload kicked off from a handler, or an async IIFE. Prefixing
it with `void` marks the ignored promise explicitly, which is the fix
the rule documents.
- It deliberately does not add `.catch()` everywhere. These call sites
already handle failures inside the async function or surface them
through their own state, and empty handlers would hide errors.
- Async IIFEs become `void (async () => …)()`. `if (cond) call()`
becomes `if (cond) void call()`.
- There is no behaviour change. `void` only discards the promise value,
which was already being discarded.

Each insertion is placed at the exact `textRange` start offset of the
SonarCloud finding, with no other edits.

## Testing

- Each changed file parses with `esbuild <file>`, using the same check
as #179.
- I parsed each changed file with the TypeScript 5.9 compiler API
(`createSourceFile`). That found no parse errors, and every inserted
`void` is either a whole expression statement or an arrow-function body
applied to a call, never part of a larger expression.
- `frontend/.eslintrc.json` has no `no-void` rule.
- I did not run the full Nx type-check, lint or test targets, because
`frontend/node_modules` is not installed in my environment. Please rely
on CI.
- No tests are added. Discarding an already-discarded promise value is
not observable, so no test can fail before this change and pass after
it.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Chores**
* Updated background tasks across uploads, sign-in, polling, scene
editing, and other app workflows to handle asynchronous operations
consistently.
* Existing workflows and their success and error handling remain
unchanged; no user-facing behavior changes are expected.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-10 21:35:11 +01:00
FredApps c9da5cac18 tauri-events/tauri-api/tauri-utils: mark fire-and-forget promises with void (Sonar S9383) (#2805)
## Summary

Resolves all 53 SonarCloud **typescript:S9383** findings ("Promises must
be awaited, end with a call to .catch, end with a call to .then with a
rejection handler or be explicitly marked as ignored with the `void`
operator") in the Tauri frontend libraries:

| Library | Findings | What is marked |

|----------------------------|----------|------------------------------------------------------------------------|
| `frontend/libs/tauri-events` | 48 | `setup()` and the `unlisten.then(f
=> f())` cleanups in each `use*Event` hook |
| `frontend/libs/tauri-api` | 4 | Same pattern in
`CanvasBgRemovedEvent`, plus `setupListener()` in `useSoraLoginListener`
|
| `frontend/libs/tauri-utils` | 1 | The async IIFE in
`useTauriWindowControls` |

This is part 1 of 3 for artcraft's 157 S9383 findings. Part 2
(`apps/artcraft/app`) is #2806, and part 3 (`libs/components`) is #2807.

## Approach

This ports the approach of storytold/artcraft-services#179, which fixes
the same rule in artcraft-services. **#179 is still open and unmerged.**
This PR follows its approach and does not depend on it.

- Each flagged expression is a fire-and-forget promise: a listener
`setup()`, an `unlisten.then(f => f())` cleanup, a loader called from an
effect, an upload kicked off from a handler, or an async IIFE. Prefixing
it with `void` marks the ignored promise explicitly, which is the fix
the rule documents.
- It deliberately does not add `.catch()` everywhere. These call sites
already handle failures inside the async function or surface them
through their own state, and empty handlers would hide errors.
- Async IIFEs become `void (async () => …)()`. `if (cond) call()`
becomes `if (cond) void call()`.
- There is no behaviour change. `void` only discards the promise value,
which was already being discarded.

Each insertion is placed at the exact `textRange` start offset of the
SonarCloud finding, with no other edits.

## Testing

- Each changed file parses with `esbuild <file>`, using the same check
as #179.
- I parsed each changed file with the TypeScript 5.9 compiler API
(`createSourceFile`). That found no parse errors, and every inserted
`void` is either a whole expression statement or an arrow-function body
applied to a call, never part of a larger expression.
- `frontend/.eslintrc.json` has no `no-void` rule.
- I did not run the full Nx type-check, lint or test targets, because
`frontend/node_modules` is not installed in my environment. Please rely
on CI.
- No tests are added. Discarding an already-discarded promise value is
not observable, so no test can fail before this change and pass after
it.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Refactor**
* Clarified that asynchronous event listener setup, cleanup, and
window-control initialization run without blocking. Existing
subscription behavior and cleanup conditions remain unchanged; no
user-facing functionality changes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-10 21:21:36 +01:00
FredApps 83aad5eb54 Exclude TypeScript and JavaScript from Sonar coverage (#2813)
## Summary

Adds `sonar.coverage.exclusions=**/*.ts,**/*.tsx,**/*.js,**/*.jsx` to
`sonar-project.properties`. There were no existing exclusion entries.

The shared SonarCloud workflow only publishes the Rust lcov report
(`sonar.rust.lcov.reportPaths=lcov.info`). Artcraft's workflow wrapper
does not pass a TypeScript or JavaScript coverage report, so those files
can never appear covered.

That is why [#2805](https://github.com/storytold/artcraft/pull/2805) and
[#2806](https://github.com/storytold/artcraft/pull/2806) fail the
quality gate on **new_coverage** (0.0% against the 80% target) after the
organisation was recreated. They only add `void` in front of existing
TypeScript calls:

- #2805: 53 new lines to cover, all uncovered
- #2806: 38 new lines to cover, all uncovered

[#2807](https://github.com/storytold/artcraft/pull/2807) also touches
TypeScript (`void` in `libs/components`) and is listed here for the same
exclusion, but its current gate failure is duplicated new code (10.6%),
which this PR does not address.

Same approach as
[anicraft#106](https://github.com/storytold/anicraft/pull/106), which
excluded JS, PowerShell and Python for the same reason. filmcraft and
lightcraft already exclude `**/tools/**,**/docs/**,**/examples/**`. This
PR uses extensions because the uncovered TypeScript and JavaScript live
under `frontend/`.

No product code changes. No open PR already edits
`sonar-project.properties`.

## Testing

Visual review of `sonar-project.properties` against anicraft, filmcraft
and lightcraft, and of the shared `sonarcloud.yml` coverage inputs. No
Rust or frontend tests were run (properties file only). The quality-gate
effect on #2805 and #2806 will be visible after this merges and those
branches are updated from main.

- [x] Clean room: no proprietary code, scripts or assets copied
(AGENTS.md)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Chores**
* Updated project configuration for coverage reporting. No user-facing
changes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-10 21:07:25 +01:00
192 changed files with 1119 additions and 1261 deletions
+1 -1
View File
@@ -80,7 +80,7 @@ jobs:
working-directory: frontend
- run: ./node_modules/.bin/nx run artcraft:build
working-directory: frontend
- uses: tauri-apps/tauri-action@fce9c6108b31ea247710505d3aaaa893ee6768d4 # v0
- uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0
with:
projectPath: crates/desktop/artcraft
args: --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} --config ${{ github.workspace }}/packaging/linux/tauri.conf.json
+5 -6
View File
@@ -46,7 +46,7 @@ jobs:
node-version: lts/*
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
targets: ${{ matrix.platform == 'macos-latest-xlarge' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
@@ -72,7 +72,7 @@ jobs:
npm cache clean --force
echo "Installing all workspace dependencies..."
npm ci
npm ci --ignore-scripts
echo "=== Version Information ==="
echo "Node version:"
@@ -205,9 +205,8 @@ jobs:
# Increase Node.js memory limit for the build
export NODE_OPTIONS="--max-old-space-size=8192"
# Use npx to ensure we use the local nx version
echo "Building with npx nx..."
npx nx run artcraft:build
echo "Building with nx..."
./node_modules/.bin/nx run artcraft:build
echo "Checking build output..."
# shellcheck disable=SC2012
@@ -226,7 +225,7 @@ jobs:
- name: build and publish
uses: tauri-apps/tauri-action@v0
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_ID: ${{ secrets.APPLE_ID }}
@@ -36,7 +36,7 @@ jobs:
- uses: actions/checkout@v7
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- name: install wreq dependencies
run: |
@@ -61,7 +61,7 @@ jobs:
npm cache clean --force
echo "Installing all workspace dependencies..."
npm ci
npm ci --ignore-scripts
echo "=== Version Information ==="
echo "Node version:"
@@ -101,7 +101,7 @@ jobs:
$config | ConvertTo-Json -Depth 10 | Set-Content tauri.conf.json
- name: build and publish
uses: tauri-apps/tauri-action@v0
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0
env:
# NB(bt): This is needed by wreq. Hopefully it doesn't kill Tauri
RUSTFLAGS: "-C target-feature=+crt-static"
Generated
+1
View File
@@ -8075,6 +8075,7 @@ dependencies = [
"sqlx",
"tempfile",
"tokens",
"tokio",
]
[[package]]
@@ -21,10 +21,6 @@ impl TaskId {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn to_string(&self) -> String {
self.0.to_string()
}
}
impl Display for TaskId {
@@ -141,8 +141,8 @@ mod tests {
async fn test_requests() -> AnyhowResult<()> {
setup_test_logging(LevelFilter::Debug);
let refresh_token = "AMf-vByUgA0J9S93vULECe-sD50cbR85AFVYF60gKAmLFF1MBYwXlKRJCsv7z3oSpLtP0ApyOU5fAl52qHWM2U0yKgEu5gQ5UCUDt5SEG4UBRHWvHEmLLcu-Zl04Fq4ljocSEO3qJzuX1wyshmptZxlDPRkjdLYgyn-Kp03woI2yRPKLuQF5hQtABHabsNUh9Zhs129sgxuUlOuw4zsFI4L8UdE3bEuF3k6Mic7KlE3440YwyQ8Qtmk9zHtXIx2ob56N-1WTGSqwMUuQ1mX4OOvLve9bw2zDW3UzchomTWkh732886RPrP-DNRkPbO3FejUEMxuYQFWYX3EEeIEdfNGXFNGPL1dNniSXlSaKadpX699Ishjviv-x9o-pPQa3zoAj3NzWLyd8mn5rDBh9qAO_iDETaeo01Sjhb4mGYfAWZ-g4S6nW778";
let refresh_token = WorldLabsRefreshToken::new(refresh_token.to_string());
let refresh_token = std::env::var("WORLDLABS_REFRESH_TOKEN")?;
let refresh_token = WorldLabsRefreshToken::new(refresh_token);
let result = google_refresh_token(GoogleRefreshTokenArgs {
refresh_token: &refresh_token,
@@ -31,8 +31,6 @@ use sqlite_tasks::queries::task::Task;
use sqlite_tasks::queries::update_successful_task_status_with_metadata::{
update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs,
};
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use std::time::Duration;
use tauri::AppHandle;
@@ -255,8 +253,7 @@ async fn download_file(
let filename = format!("fal_{}_{}.{}", generate_random_uuid(), index, extension);
let download_path = tempdir.join(filename);
let mut file = File::create(&download_path)?;
file.write_all(&bytes)?;
tokio::fs::write(&download_path, bytes).await?;
Ok(download_path)
}
@@ -94,7 +94,7 @@ async fn download_generated_results(
}
ensure!(!urls.is_empty(), "Completed task has no downloadable results");
let path = checkpoint_path(root, task);
let mut checkpoint: AutoDownloadCheckpoint = match std::fs::read(&path) {
let mut checkpoint: AutoDownloadCheckpoint = match tokio::fs::read(&path).await {
Ok(bytes) => serde_json::from_slice(&bytes)?,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
AutoDownloadCheckpoint { download_time: Local::now(), saved: BTreeMap::new() }
@@ -1,8 +1,6 @@
use errors::AnyhowResult;
use reqwest::Client;
use reqwest::Url;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
const USER_AGENT: &str = "storyteller-client/1.0";
@@ -22,13 +20,7 @@ pub async fn simple_http_download<P: AsRef<Path>>(url: &Url, download_path: P) -
//let response : Response = reqwest::get(url).await?;
let bytes = response.bytes().await?;
let mut file = OpenOptions::new()
.create(true) // To create a new file
.write(true)
.truncate(true)
.open(download_path)?;
file.write_all(&bytes)?;
tokio::fs::write(download_path, bytes).await?;
Ok(())
}
@@ -33,8 +33,6 @@ use sqlite_tasks::queries::task::Task;
use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs};
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::endpoints::media_files::get_media_file::get_media_file;
@@ -213,8 +211,7 @@ async fn download_generation_item(
info!("Writing to path: {:?}", download_path);
let mut file = File::create(&download_path)?;
file.write_all(&image_bytes)?;
tokio::fs::write(&download_path, image_bytes).await?;
Ok(download_path)
}
@@ -39,8 +39,6 @@ use sqlite_tasks::queries::task::Task;
use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs};
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use std::time::Duration;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
@@ -378,8 +376,7 @@ async fn download_spz(
info!("Writing to path: {:?}", download_path);
let mut file = File::create(&download_path)?;
file.write_all(&image_bytes)?;
tokio::fs::write(&download_path, image_bytes).await?;
Ok(download_path)
}
@@ -19,6 +19,7 @@ tokens.workspace = true
anyhow.workspace = true
chrono.workspace = true
log.workspace = true
tokio = { workspace = true, features = ["fs"] }
# TODO(bt,2025-07-09): Update sqlx ASAP. We're stuck on 0.7 because of the complex use of internal types and macros in the database crate.
# We're going to embed in-memory SQLite
@@ -22,7 +22,7 @@ impl TaskDbConnection {
info!("Deleting and recreating SQLite database at {:?}", database_file.as_ref());
if let Err(err) = std::fs::remove_file(&database_file) {
if let Err(err) = tokio::fs::remove_file(&database_file).await {
error!("Error deleting SQLite database file: {:?}", err);
}
+3 -3
View File
@@ -5,14 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="SwaggerUI" />
<title>Storyteller API</title>
<link rel="stylesheet" href="//unpkg.com/swagger-ui-dist@5.32.0/swagger-ui.css" />
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.32.0/swagger-ui.css" integrity="sha384-3nuX7df3EaAoiqLBeyS1Ola0Gpg9ryJKVtarubwfnA1cOH8AWHUdbPSIvEqPZ9VH" crossorigin="anonymous" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="//unpkg.com/swagger-ui-dist@5.32.0/swagger-ui-bundle.js" crossorigin></script>
<script src="//unpkg.com/swagger-ui-dist@5.32.0/swagger-ui-standalone-preset.js" crossorigin></script>
<script src="https://unpkg.com/swagger-ui-dist@5.32.0/swagger-ui-bundle.js" integrity="sha384-7xcoc6ZKDFF7Ek627QTC3Bg/K+5Y36NJ8MWAE43D2m6+3Sh9XO3tdsfHhrS8gNIQ" crossorigin="anonymous"></script>
<script src="https://unpkg.com/swagger-ui-dist@5.32.0/swagger-ui-standalone-preset.js" integrity="sha384-FjFI+0PRyd5aAEoduVmUYk65iDC5+onNzam4WJtbAKKBim/kWvGUMk/2+9qRaYZb" crossorigin="anonymous"></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
@@ -120,7 +120,7 @@ export function GlobalFileDropHandler() {
}
};
setup();
void setup();
return () => { unlisteners.forEach((fn) => fn()); };
} else {
@@ -38,6 +38,7 @@ import {
} from "@storyteller/ui-pricing-modal";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
import { CreateEmptyState } from "~/components/generation-feed/CreateEmptyState";
import { clickOnEnterOrSpace } from "@storyteller/common";
type Mode = "image" | "text";
type Variant = "object" | "world";
@@ -69,10 +70,7 @@ const formatTime = (timestamp: number) => {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
const generateId = () =>
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2, 10);
const generateId = () => uuidv4();
const WORLD_MODEL_PAGE = ModelPage.ImageTo3DWorld;
const OBJECT_MODEL_PAGE = ModelPage.ImageTo3DObject;
@@ -256,7 +254,7 @@ export const ImageTo3DExperience = ({
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
handleFiles(event.target.files);
void handleFiles(event.target.files);
event.target.value = "";
};
@@ -264,7 +262,7 @@ export const ImageTo3DExperience = ({
event.preventDefault();
event.stopPropagation();
setDragActive(false);
handleFiles(event.dataTransfer?.files);
void handleFiles(event.dataTransfer?.files);
};
const handlePickFromLibrary = () => {
@@ -599,6 +597,9 @@ export const ImageTo3DExperience = ({
<div className="flex justify-center">
{uploadedPreview ? (
<div
role="button"
tabIndex={0}
onKeyDown={clickOnEnterOrSpace}
className={twMerge(
"group relative cursor-pointer overflow-hidden border border-white/30 bg-black/30 transition-all hover:border-white/60",
hasResults ? "aspect-square w-24" : "aspect-square w-48",
@@ -716,7 +717,7 @@ export const ImageTo3DExperience = ({
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer?.files)
handleWorldFiles(
void handleWorldFiles(
Array.from(e.dataTransfer.files).filter((f) =>
f.type.startsWith("image/"),
),
@@ -754,6 +755,9 @@ export const ImageTo3DExperience = ({
const worldImageThumbnail = (img: WorldImage) => (
<div
role="button"
tabIndex={0}
onKeyDown={clickOnEnterOrSpace}
key={img.id}
className={twMerge(
"group relative aspect-square overflow-hidden rounded-[3px] border border-white/30 bg-black/30 transition-all",
@@ -944,7 +948,7 @@ export const ImageTo3DExperience = ({
<div
className={twMerge(
"mx-auto grid h-full w-full grid-cols-[1fr_300px] gap-4 overflow-hidden pb-10",
variant === "world" ? "max-w-[1600px]" : "max-w-[1600px]",
"max-w-[1600px]",
)}
style={{ height: `calc(100vh - ${bottomOffsetPx + 80}px)` }}
>
@@ -1111,7 +1115,7 @@ export const ImageTo3DExperience = ({
<div
className={twMerge(
"flex items-center justify-between gap-2.5",
hasResults ? "mt-3" : "mt-3",
"mt-3",
)}
>
<ClassyModelSelector
@@ -1177,7 +1181,7 @@ export const ImageTo3DExperience = ({
className="hidden"
onChange={(e) => {
if (e.target.files) {
handleWorldFiles(Array.from(e.target.files));
void handleWorldFiles(Array.from(e.target.files));
}
e.target.value = "";
}}
@@ -1202,6 +1206,7 @@ export const ImageTo3DExperience = ({
{previewImage && (
<div
role="presentation"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
onClick={() => setPreviewImage(null)}
>
@@ -248,7 +248,7 @@ function ItemActions({
e.stopPropagation();
const url = item.fullImage || item.thumbnail;
if (!url) return;
applyMakeVideoFromImage(url, item.id);
void applyMakeVideoFromImage(url, item.id);
},
[item.fullImage, item.thumbnail, item.id],
);
@@ -39,6 +39,7 @@ import {
type GalleryItem,
} from "@storyteller/ui-generation-list";
import { useTabStore } from "~/pages/Stores/TabState";
import { v4 as uuidv4 } from "uuid";
// Media item actions shared by the TopBar gallery modal / lightbox wiring and
// the create pages' generation feed (rows + cards). All of them operate on
@@ -130,7 +131,7 @@ export async function downloadMediaFilesToFolder(
export async function applyMakeVideoFromImage(url: string, mediaToken?: string) {
try {
const referenceImage: RefImage = {
id: Math.random().toString(36).substring(7),
id: uuidv4(),
url,
file: new File([], "library-image"),
mediaToken: mediaToken || "",
@@ -178,7 +179,7 @@ export function applyRecreateFromPromptData(data: {
for (const ci of contextImages) {
const base = {
id: Math.random().toString(36).substring(7),
id: uuidv4(),
url: ci.media_links.cdn_url,
mediaToken: ci.media_token,
};
@@ -295,8 +295,8 @@ export function useDesktopGenerationFeed(options: {
cancelled = true;
clearInterval(intervalId);
window.removeEventListener("task-queue-update", handleTaskUpdate);
unlistenComplete.then((f) => f());
unlistenFailed.then((f) => f());
void unlistenComplete.then((f) => f());
void unlistenFailed.then((f) => f());
};
}, [load]);
@@ -8,7 +8,7 @@ export function useDesktopUsername(): string | null {
useEffect(() => {
let cancelled = false;
(async () => {
void (async () => {
try {
const session = await new UsersApi().GetSession();
if (!cancelled && session.success && session.data?.user) {
@@ -66,9 +66,9 @@ export const CostModal = ({ credits = 1, onClose }: CostModalProps) => {
</div>
<div className="space-y-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
Currency
</label>
</span>
<Select
options={selectOptions}
value={currency}
@@ -48,7 +48,7 @@ export const UploadFilesMedia = ({
return;
}
uploadPlane({
void uploadPlane({
title: uploadTitle.value,
assetFile: assetFile.value,
progressCallback: onUploadProgress,
@@ -0,0 +1,75 @@
import { PauseIcon, PlayIcon, SkipBackIcon, SkipForwardIcon, Volume2Icon, VolumeXIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
interface VideoTransportControlsProps {
isPlaying: boolean;
isMuted: boolean;
volume: number;
currentTime: number;
duration: number;
onSeek: (to: "first" | "last") => void;
onTogglePlay: () => void;
onToggleMute: () => void;
onVolumeChange: (volume: number) => void;
formatTime: (seconds: number) => string;
}
/** The row under a video page's player: first / play / last frame, mute and volume, and the time. */
export const VideoTransportControls = ({
isPlaying,
isMuted,
volume,
currentTime,
duration,
onSeek,
onTogglePlay,
onToggleMute,
onVolumeChange,
formatTime,
}: VideoTransportControlsProps) => (
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<button
onClick={() => onSeek("first")}
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
title="First Frame"
>
<SkipBackIcon className="text-sm text-white" />
</button>
<button
onClick={onTogglePlay}
aria-label={isPlaying ? "Pause" : "Play"}
className="flex h-9 w-9 items-center justify-center rounded-[3px] bg-white/20 transition-colors hover:bg-white/30"
>
<DynamicIcon icon={isPlaying ? PauseIcon : PlayIcon} className="text-white" />
</button>
<button
onClick={() => onSeek("last")}
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
title="Last Frame"
>
<SkipForwardIcon className="text-sm text-white" />
</button>
<div className="mx-1 h-6 w-px bg-white/20" />
<button
onClick={onToggleMute}
aria-label={isMuted ? "Unmute" : "Mute"}
className="flex h-7 w-7 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
>
<DynamicIcon icon={isMuted ? VolumeXIcon : Volume2Icon} className="text-sm text-white" />
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={(e) => onVolumeChange(Number.parseFloat(e.target.value))}
className="w-20 accent-white"
/>
</div>
<div className="font-mono text-sm tabular-nums text-white">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
);
@@ -26,7 +26,7 @@ export default function ProfileDropdown() {
label: "Logout",
icon: LogOutIcon,
onClick: () => {
logout();
void logout();
},
},
];
@@ -67,7 +67,7 @@ export const SceneTitleInput = ({ pageName }: Props) => {
e.currentTarget.focus();
} else if (scene.value.token) {
setIsSaving(true);
renameScene(
void renameScene(
scene.value.title!, //guarunteed by input
scene.value.token,
);
@@ -164,7 +164,7 @@ const CopyPromptButton = ({ prompt }: { prompt: string }) => {
aria-label="Copy prompt"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(prompt);
void navigator.clipboard.writeText(prompt);
setCopied(true);
setTimeout(() => setCopied(false), 3000);
}}
@@ -513,7 +513,7 @@ export const TaskQueue = () => {
const [failed, setFailed] = useState<FailedTask[]>([]);
const [lastReadAt, setLastReadAt] = useState<number>(() => {
const stored = localStorage.getItem("taskQueueLastReadAt");
return stored ? parseInt(stored, 10) : 0;
return stored ? Number.parseInt(stored, 10) : 0;
});
// remove unread state; unread tracking handled via IDs below
@@ -869,7 +869,7 @@ export const TaskQueue = () => {
};
cleanupOldEntries();
load();
void load();
const id = setInterval(load, 5000);
// Listen for cover image uploads to refresh and show new thumbnails
@@ -883,17 +883,17 @@ export const TaskQueue = () => {
let unlistenComplete: Promise<UnlistenFn> | null = null;
let unlistenFailed: Promise<UnlistenFn> | null = null;
(async () => {
void (async () => {
// Update immediately when Tauri signals a generation completion
unlistenComplete = listen("generation-complete-event", () => {
if (!cancelled) {
load();
void load();
}
});
// Also update immediately when a generation fails
unlistenFailed = listen("generation-failed-event", () => {
if (!cancelled) {
load();
void load();
// Refunded credits may take a moment to settle in the database
setTimeout(() => {
useCreditsState.getState().fetchFromServer();
@@ -906,10 +906,10 @@ export const TaskQueue = () => {
clearInterval(id);
window.removeEventListener("cover-image-uploaded", handleCoverUploaded);
if (unlistenComplete) {
unlistenComplete.then((f) => f());
void unlistenComplete.then((f) => f());
}
if (unlistenFailed) {
unlistenFailed.then((f) => f());
void unlistenFailed.then((f) => f());
}
};
}, [
@@ -125,7 +125,7 @@ export function storeEnqueueMeta(meta: EnqueueMeta) {
// Then convert blob URLs to base64 in the background for persistence
if (meta.refImageUrls && meta.refImageUrls.length > 0) {
Promise.all(meta.refImageUrls.map(toBase64Thumb)).then((dataUrls) => {
void Promise.all(meta.refImageUrls.map(toBase64Thumb)).then((dataUrls) => {
const current = readEntries();
const found = current.find((e) => e.id === entryId);
if (found) {
@@ -12,7 +12,7 @@ export const withProtectionRoute = <P extends object>(
function ProtectionRoute(rest: P) {
useSignals();
useSignalEffect(() => {
persistLogin();
void persistLogin();
});
// const { status, userInfo } = authentication;
@@ -38,7 +38,7 @@ export const useActiveJobs = () => {
// then set new one with new interval
clearInterval(curr.timer);
const newTimer = setInterval(() => {
PollRecentJobs();
void PollRecentJobs();
}, newTimeInterval);
return {
timer: newTimer,
@@ -72,7 +72,7 @@ export const useActiveJobs = () => {
useSignalEffect(() => {
// if active jobs is not initiated, do do a first pull;
if (!activeJobs.value) {
PollRecentJobs();
void PollRecentJobs();
return;
}
// if there are no active jobs, stop making polls
@@ -115,7 +115,7 @@ export const useActiveJobs = () => {
//else make and set timer
const newTimer = setInterval(() => {
PollRecentJobs();
void PollRecentJobs();
}, curr.interval);
return {
...curr,
@@ -24,7 +24,7 @@ export const useBackgroundLoadingMedia = () => {
//CASE 1: first load
// if myMovies undefined, poll for the first time
if (!userMovies.value) {
PollUserGeneratedMovies();
void PollUserGeneratedMovies();
return;
}
@@ -45,7 +45,7 @@ export const useBackgroundLoadingMedia = () => {
//there are videos newly completed, set and poll
lastCompletedWorkflow.current = completedWorkflowJobs.value;
PollUserGeneratedMovies().then((ret: boolean) => {
void PollUserGeneratedMovies().then((ret: boolean) => {
if (ret) {
addToast(
ToastTypes.SUCCESS,
@@ -60,7 +60,7 @@ export const useBackgroundLoadingMedia = () => {
//CASE 1: first load
// if audioItems undefined, poll for the first time
if (!userAudioItems.value) {
PollUserAudioItems();
void PollUserAudioItems();
return;
}
@@ -82,7 +82,7 @@ export const useBackgroundLoadingMedia = () => {
//there are audio jobs newly completed, set and poll
lastCompletedAudioJobs.current = completedAudioJobs.value;
PollUserAudioItems().then((ret: boolean) => {
void PollUserAudioItems().then((ret: boolean) => {
if (ret) {
addToast(
ToastTypes.SUCCESS,
+2 -2
View File
@@ -26,12 +26,12 @@ const ENV = {
const GlobalSettingsManager = ({ env }: { env: Record<string, string> }) => {
console.log("GlobalSettingsManager()");
SyncStorytellerApiConfig();
void SyncStorytellerApiConfig();
useSignals();
useSignalEffect(() => {
persistLogin();
void persistLogin();
});
/// Initizations that depends on ENV vars ///
@@ -152,7 +152,7 @@ export const MainApp = ({ sceneToken }: Props) => {
// Session probe (runs once per shell mount).
useEffect(() => {
const usersApi = new UsersApi();
usersApi.GetSession().then((result) => {
void usersApi.GetSession().then((result) => {
console.log(
`User Info | Username: ${result.data?.user?.username}, Token: ${result.data?.user?.user_token}`,
);
@@ -11,6 +11,7 @@ import { CostCalculatorButton } from "@storyteller/ui-pricing-modal";
import { ModelPage } from "@storyteller/ui-model-selector";
import { UploadEntryCard } from "~/components/media/UploadEntryCard";
import { BlankCanvasModal, type BaseSelectorImage } from "@storyteller/ui-pagedraw";
import { v4 as uuidv4 } from "uuid";
const MAX_GALLERY_SELECTIONS = 1;
@@ -64,7 +65,7 @@ export const BaseImageSelector = ({
setIsBlankCanvasModalOpen(false);
const blankImage: BaseSelectorImage = {
url: "",
mediaToken: `blank_canvas_${width}x${height}_${Math.random().toString(36).substring(2, 8)}`,
mediaToken: `blank_canvas_${width}x${height}_${uuidv4()}`,
isBlankCanvas: true,
blankCanvasWidth: width,
blankCanvasHeight: height,
@@ -79,12 +80,12 @@ export const BaseImageSelector = ({
const reader = new FileReader();
reader.onloadend = () => {
uploadImage({
title: `reference-image-${Math.random().toString(36).substring(2, 15)}`,
title: `reference-image-${uuidv4()}`,
assetFile: file,
progressCallback: (newState) => {
if (newState.status === UploaderStates.success && newState.data) {
const mediaToken = newState.data || "";
(async () => {
void (async () => {
let finalUrl = reader.result as string;
let thumbnailUrlTemplate = undefined;
try {
@@ -1,6 +1,7 @@
import { create } from "zustand";
import type { GeneratedImage } from "@storyteller/tauri-events";
import type { ModelCreator } from "@storyteller/model-list";
import { v4 as uuidv4 } from "uuid";
export type TextToImageBatch = {
id: string;
@@ -43,11 +44,7 @@ export const useTextToImageStore = create<TextToImageState>((set, get) => ({
subscriberId?: string,
modelCreator?: ModelCreator,
) => {
const id = subscriberId
? subscriberId
: crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2);
const id = subscriberId || uuidv4();
const batch: TextToImageBatch = {
id,
prompt,
@@ -3,6 +3,7 @@ import { listen } from "@tauri-apps/api/event";
import { MediaFilesApi, MediaUploadApi } from "@storyteller/api";
import { v4 as uuidv4 } from "uuid";
import { captureModelThumbnail } from "./captureModelThumbnail";
import { completeGenerationAction } from "../Stores/completeGenerationResult";
export interface PendingExternalImage {
url: string;
@@ -72,11 +73,7 @@ export const useImageTo3DStore = create<ImageTo3DState>((set, get) => ({
meshOnly: boolean,
subscriberId?: string,
) => {
const id = subscriberId
? subscriberId
: crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2);
const id = subscriberId || uuidv4();
const result: ImageTo3DResult = {
id,
mode,
@@ -90,52 +87,7 @@ export const useImageTo3DStore = create<ImageTo3DState>((set, get) => ({
set((s) => ({ results: [result, ...s.results] }));
return id;
},
completeGeneration: (
modelUrl: string,
mediaToken: string,
maybeSubscriberId?: string,
) => {
console.log("[ImageTo3DStore] completeGeneration", {
modelUrl,
mediaToken,
maybeSubscriberId,
});
const pending = maybeSubscriberId
? get().results.find((r) => r.subscriberId === maybeSubscriberId)
: get().results.find((r) => r.status === "pending");
set((s) => {
const results = [...s.results];
const targetIdx = pending
? results.findIndex((r) => r.subscriberId === pending.subscriberId)
: -1;
if (targetIdx === -1) {
const generatedId =
crypto.randomUUID?.() ?? Math.random().toString(36).slice(2);
const result: ImageTo3DResult = {
id: generatedId,
subscriberId: generatedId,
mode: "image",
timestamp: Date.now(),
note: "Generated Model",
status: "completed",
modelUrl,
mediaToken,
};
return { results: [result, ...results] };
}
results[targetIdx] = {
...results[targetIdx],
status: "completed",
modelUrl,
mediaToken,
};
return { results };
});
},
completeGeneration: completeGenerationAction(set, "ImageTo3DStore", "Generated Model"),
uploadCoverImage: async (mediaToken: string, thumbnailDataUrl: string) => {
try {
// Check if already uploaded
@@ -2,6 +2,7 @@ import { create } from "zustand";
import { listen } from "@tauri-apps/api/event";
import { MediaFilesApi, MediaUploadApi } from "@storyteller/api";
import { v4 as uuidv4 } from "uuid";
import { completeGenerationAction } from "../Stores/completeGenerationResult";
export type ImageTo3DWorldResult = {
id: string;
@@ -54,11 +55,7 @@ export const useImageTo3DWorldStore = create<ImageTo3DWorldState>(
previewUrl: string | undefined,
subscriberId?: string,
) => {
const id = subscriberId
? subscriberId
: crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2);
const id = subscriberId || uuidv4();
const result: ImageTo3DWorldResult = {
id,
mode,
@@ -71,52 +68,7 @@ export const useImageTo3DWorldStore = create<ImageTo3DWorldState>(
set((s) => ({ results: [result, ...s.results] }));
return id;
},
completeGeneration: (
modelUrl: string,
mediaToken: string,
maybeSubscriberId?: string,
) => {
console.log("[ImageTo3DWorldStore] completeGeneration", {
modelUrl,
mediaToken,
maybeSubscriberId,
});
const pending = maybeSubscriberId
? get().results.find((r) => r.subscriberId === maybeSubscriberId)
: get().results.find((r) => r.status === "pending");
set((s) => {
const results = [...s.results];
const targetIdx = pending
? results.findIndex((r) => r.subscriberId === pending.subscriberId)
: -1;
if (targetIdx === -1) {
const generatedId =
crypto.randomUUID?.() ?? Math.random().toString(36).slice(2);
const result: ImageTo3DWorldResult = {
id: generatedId,
subscriberId: generatedId,
mode: "image",
timestamp: Date.now(),
note: "Generated World",
status: "completed",
modelUrl,
mediaToken,
};
return { results: [result, ...results] };
}
results[targetIdx] = {
...results[targetIdx],
status: "completed",
modelUrl,
mediaToken,
};
return { results };
});
},
completeGeneration: completeGenerationAction(set, "ImageTo3DWorldStore", "Generated World"),
uploadCoverFromPreview: async (mediaToken: string) => {
try {
const result = get().results.find((r) => r.mediaToken === mediaToken);
@@ -148,7 +148,7 @@ export const RemoveBackground = () => {
}
};
processExternalUrl();
void processExternalUrl();
}, [
pendingExternalUrl,
setPendingExternalUrl,
@@ -4,6 +4,7 @@ import { DynamicIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
import { twMerge } from "tailwind-merge";
import { useStoryboardStore, type Board } from "./StoryboardStore";
import { clickOnEnterOrSpace } from "@storyteller/common";
const PIXELS_PER_SECOND = 80;
const MIN_BLOCK_WIDTH = 40;
@@ -73,8 +74,9 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
</div>
<div>
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Title</label>
<label htmlFor="storyboard-title" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Title</label>
<input
id="storyboard-title"
type="text"
value={board.title}
onChange={(e) => onUpdate({ title: e.target.value })}
@@ -84,8 +86,9 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
</div>
<div>
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Dialogue</label>
<label htmlFor="storyboard-dialogue" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Dialogue</label>
<textarea
id="storyboard-dialogue"
value={board.dialogue}
onChange={(e) => onUpdate({ dialogue: e.target.value })}
placeholder="Character dialogue..."
@@ -95,8 +98,9 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
</div>
<div>
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Action</label>
<label htmlFor="storyboard-action" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Action</label>
<textarea
id="storyboard-action"
value={board.action}
onChange={(e) => onUpdate({ action: e.target.value })}
placeholder="On-screen action..."
@@ -106,8 +110,9 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
</div>
<div>
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Notes</label>
<label htmlFor="storyboard-notes" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Notes</label>
<textarea
id="storyboard-notes"
value={board.notes}
onChange={(e) => onUpdate({ notes: e.target.value })}
placeholder="Director notes..."
@@ -117,13 +122,14 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
</div>
<div>
<label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Duration (seconds)</label>
<label htmlFor="storyboard-duration" className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Duration (seconds)</label>
<input
id="storyboard-duration"
type="number"
min={0}
step={0.5}
value={board.duration}
onChange={(e) => onUpdate({ duration: parseFloat(e.target.value) || 0 })}
onChange={(e) => onUpdate({ duration: Number.parseFloat(e.target.value) || 0 })}
className="w-full rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 font-mono text-sm tabular-nums text-base-fg focus:border-white/60 focus:outline-none"
/>
</div>
@@ -353,6 +359,7 @@ const SketchCanvas = ({ initialImageDataUrl, onSave, onExit }: SketchCanvasProps
{/* Color picker */}
<label className="relative flex h-8 w-8 cursor-pointer items-center justify-center rounded-[3px] border border-white/15 hover:border-white/30" title="Stroke color">
<span className="sr-only">Stroke color</span>
<span
className="h-4 w-4 rounded-sm border border-white/20"
style={{ background: color }}
@@ -457,6 +464,9 @@ const ThumbnailItem = ({
onDragEnd,
}: ThumbnailItemProps) => (
<div
role="button"
tabIndex={0}
onKeyDown={clickOnEnterOrSpace}
draggable
onDragStart={(e) => onDragStart(e, index)}
onDragOver={(e) => {
@@ -594,7 +604,7 @@ const Timeline = ({
const delta = ev.clientX - resizeRef.current.startX;
const newDuration = Math.max(
MIN_DURATION,
parseFloat((resizeRef.current.startDuration + delta / PIXELS_PER_SECOND).toFixed(1)),
Number.parseFloat((resizeRef.current.startDuration + delta / PIXELS_PER_SECOND).toFixed(1)),
);
onUpdateDuration(resizeRef.current.id, newDuration);
};
@@ -1,4 +1,5 @@
import { create } from "zustand";
import { v4 as uuidv4 } from "uuid";
export type VideoItem = {
media_token: string;
@@ -33,11 +34,7 @@ type ImageToVideoState = {
export const useImageToVideoStore = create<ImageToVideoState>((set, get) => ({
batches: [],
startBatch: (prompt: string, modelLabel: string, subscriberId?: string) => {
const id = subscriberId
? subscriberId
: crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2);
const id = subscriberId || uuidv4();
const batch: ImageToVideoBatch = {
id,
prompt,
@@ -61,7 +58,7 @@ export const useImageToVideoStore = create<ImageToVideoState>((set, get) => ({
? s.batches.findIndex((b) => b.id === pending.id)
: -1;
if (idx === -1) {
const id = Math.random().toString(36).slice(2);
const id = uuidv4();
const batch: ImageToVideoBatch = {
id,
prompt,
@@ -1,6 +1,5 @@
import { useState, useRef, useEffect } from "react";
import { CheckIcon, ImagesIcon, PauseIcon, PlayIcon, RotateCwIcon, SaveIcon, SkipBackIcon, SkipForwardIcon, SparklesIcon, VideoIcon, Volume2Icon, VolumeXIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { CheckIcon, ImagesIcon, RotateCwIcon, SaveIcon, SparklesIcon, VideoIcon } from "lucide-react";
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import {
@@ -13,6 +12,7 @@ import { v4 as uuidv4 } from "uuid";
import { usePromptVideoStore, RefImage } from "@storyteller/ui-promptbox";
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
import { useTabStore } from "~/pages/Stores/TabState";
import { VideoTransportControls } from "../../components/reusable/VideoTransportControls";
export const VideoFrameExtractor = () => {
const [videoUrl, setVideoUrl] = useState<string>("");
@@ -420,7 +420,7 @@ export const VideoFrameExtractor = () => {
const file = new File([blob], fileName, { type: "image/png" });
const referenceImage: RefImage = {
id: Math.random().toString(36).substring(7),
id: uuidv4(),
url: frameUrl,
file: file,
mediaToken: uploadResponse.data,
@@ -515,6 +515,7 @@ export const VideoFrameExtractor = () => {
/>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4">
<div
role="presentation"
ref={progressBarRef}
className="group relative mb-3 h-3 cursor-pointer bg-white/15"
onClick={handleProgressBarClick}
@@ -526,6 +527,7 @@ export const VideoFrameExtractor = () => {
}}
/>
<div
role="presentation"
className="absolute top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-ew-resize bg-yellow-400"
style={{
left: `${duration ? (startTime / duration) * 100 : 0}%`,
@@ -566,61 +568,18 @@ export const VideoFrameExtractor = () => {
}}
/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<button
onClick={() => seekToFrame("first")}
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
title="First Frame"
>
<SkipBackIcon
className="text-sm text-white" />
</button>
<button
onClick={togglePlayPause}
className="flex h-9 w-9 items-center justify-center rounded-[3px] bg-white/20 transition-colors hover:bg-white/30"
>
<DynamicIcon
icon={isPlaying ? PauseIcon : PlayIcon}
className="text-white"
/>
</button>
<button
onClick={() => seekToFrame("last")}
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
title="Last Frame"
>
<SkipForwardIcon
className="text-sm text-white" />
</button>
<div className="mx-1 h-6 w-px bg-white/20" />
<button
onClick={toggleMute}
className="flex h-7 w-7 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
>
<DynamicIcon
icon={isMuted ? VolumeXIcon : Volume2Icon}
className="text-sm text-white"
/>
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={(e) =>
setVolume(parseFloat(e.target.value))
}
className="w-20 accent-white"
/>
</div>
<div className="font-mono text-sm tabular-nums text-white">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
<VideoTransportControls
isPlaying={isPlaying}
isMuted={isMuted}
volume={volume}
currentTime={currentTime}
duration={duration}
onSeek={seekToFrame}
onTogglePlay={togglePlayPause}
onToggleMute={toggleMute}
onVolumeChange={setVolume}
formatTime={formatTime}
/>
</div>
</div>
</div>
@@ -712,7 +671,7 @@ export const VideoFrameExtractor = () => {
setNumFrames(
Math.max(
1,
Math.min(50, parseInt(e.target.value) || 1),
Math.min(50, Number.parseInt(e.target.value) || 1),
),
)
}
@@ -735,7 +694,7 @@ export const VideoFrameExtractor = () => {
value={frameDistance}
onChange={(e) =>
setFrameDistance(
Math.max(1, parseInt(e.target.value) || 1),
Math.max(1, Number.parseInt(e.target.value) || 1),
)
}
className="w-full rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 text-sm tabular-nums text-base-fg focus:border-white/60 focus:outline-none"
@@ -1,11 +1,11 @@
import { useState, useRef, useEffect } from "react";
import { DropletIcon, ImagesIcon, PauseIcon, PlayIcon, RotateCwIcon, SkipBackIcon, SkipForwardIcon, VideoIcon, Volume2Icon, VolumeXIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { DropletIcon, ImagesIcon, RotateCwIcon, VideoIcon } from "lucide-react";
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { downloadFileFromUrl } from "@storyteller/tauri-api";
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
import toast from "react-hot-toast";
import { VideoTransportControls } from "../../components/reusable/VideoTransportControls";
export const VideoWatermarkRemover = () => {
const [videoUrl, setVideoUrl] = useState<string>("");
@@ -255,6 +255,7 @@ export const VideoWatermarkRemover = () => {
/>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4">
<div
role="presentation"
ref={progressBarRef}
className="group relative mb-3 h-3 cursor-pointer bg-white/15"
onClick={handleProgressBarClick}
@@ -266,61 +267,18 @@ export const VideoWatermarkRemover = () => {
}}
/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<button
onClick={() => seekToFrame("first")}
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
title="First Frame"
>
<SkipBackIcon
className="text-sm text-white" />
</button>
<button
onClick={togglePlayPause}
className="flex h-9 w-9 items-center justify-center rounded-[3px] bg-white/20 transition-colors hover:bg-white/30"
>
<DynamicIcon
icon={isPlaying ? PauseIcon : PlayIcon}
className="text-white"
/>
</button>
<button
onClick={() => seekToFrame("last")}
className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
title="Last Frame"
>
<SkipForwardIcon
className="text-sm text-white" />
</button>
<div className="mx-1 h-6 w-px bg-white/20" />
<button
onClick={toggleMute}
className="flex h-7 w-7 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
>
<DynamicIcon
icon={isMuted ? VolumeXIcon : Volume2Icon}
className="text-sm text-white"
/>
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={(e) =>
setVolume(parseFloat(e.target.value))
}
className="w-20 accent-white"
/>
</div>
<div className="font-mono text-sm tabular-nums text-white">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
<VideoTransportControls
isPlaying={isPlaying}
isMuted={isMuted}
volume={volume}
currentTime={currentTime}
duration={duration}
onSeek={seekToFrame}
onTogglePlay={togglePlayPause}
onToggleMute={toggleMute}
onVolumeChange={setVolume}
formatTime={formatTime}
/>
</div>
</div>
</div>
@@ -0,0 +1,50 @@
import { v4 as uuidv4 } from "uuid";
interface GenerationResult {
id: string;
mode: "image" | "text";
timestamp: number;
note?: string;
status: "pending" | "completed";
subscriberId: string;
modelUrl?: string;
mediaToken?: string;
}
/**
* The results after a 3D job finishes: the one with that subscriber id (else the first still
* pending) becomes completed with the model; with none to update, a completed one is added first.
*/
export function completeGenerationResult<T extends GenerationResult>(
results: readonly T[],
subscriberId: string | undefined,
completed: { modelUrl: string; mediaToken: string; note: string },
): T[] {
const { modelUrl, mediaToken, note } = completed;
const index = subscriberId
? results.findIndex((r) => r.subscriberId === subscriberId)
: results.findIndex((r) => r.status === "pending");
if (index === -1) {
const id = uuidv4();
// The other fields of T are optional, as the 3D result types declare them.
const added = { id, subscriberId: id, mode: "image", timestamp: Date.now(), note, status: "completed", modelUrl, mediaToken } as T;
return [added, ...results];
}
const next = [...results];
next[index] = { ...next[index], status: "completed", modelUrl, mediaToken };
return next;
}
/** A store's `completeGeneration`: logs the finished job under `store` and applies completeGenerationResult. */
export function completeGenerationAction<T extends GenerationResult>(
set: (update: (state: { results: T[] }) => { results: T[] }) => void,
store: string,
note: string,
) {
return (modelUrl: string, mediaToken: string, maybeSubscriberId?: string) => {
console.log(`[${store}] completeGeneration`, { modelUrl, mediaToken, maybeSubscriberId });
set((s) => ({
results: completeGenerationResult(s.results, maybeSubscriberId, { modelUrl, mediaToken, note }),
}));
};
}
@@ -87,7 +87,7 @@ export const completedAudioJobs = computed(() => {
export const shouldPollActiveJobs = signal<boolean>(false);
export const startPollingActiveJobs = () => {
PollRecentJobs(); //poll once regardless of polling intervals
void PollRecentJobs(); //poll once regardless of polling intervals
shouldPollActiveJobs.value = true;
};
@@ -47,7 +47,7 @@ export const login = async ({
// technically user is login with the system now, HOWEVER,
// in storyteller studio, only having a sesison is not enough,
// we need session info and active subscription info as well
getUserInfoAndSubcriptions();
void getUserInfoAndSubcriptions();
window.location.href = "/"; // TODO(bt,2025-04-19): Once we have in-page routing, get rid of this.
};
@@ -86,7 +86,7 @@ export const signUp = async ({
// technically user is login with the system now, HOWEVER,
// in storyteller studio, only having a sesison is not enough,
// we need session info and active subscription info as well
getUserInfoAndSubcriptions();
void getUserInfoAndSubcriptions();
return "";
};
@@ -95,12 +95,12 @@ export const persistLogin = async () => {
if (authentication.status.value !== AUTH_STATUS.INIT) {
return;
}
getUserInfoAndSubcriptions();
void getUserInfoAndSubcriptions();
};
// NB: Only for SyncStorytellerApiConfig.
export const forceGetUserInfoAndSubcriptions = async () => {
getUserInfoAndSubcriptions();
void getUserInfoAndSubcriptions();
};
async function getUserInfoAndSubcriptions() {
+1 -1
View File
@@ -40,7 +40,7 @@ export const isJobStatusTerminal = (curr: JobStatus) => {
};
export const isNumberString = (value: string) => {
return !isNaN(Number(value));
return !Number.isNaN(Number(value));
};
export const isJobStatusError = (curr: JobStatus) => {
+1 -1
View File
@@ -17,7 +17,7 @@ set -euxo pipefail
echo 'TEMPORARY HACK: Build internal dependencies of artcraft since nx is not finding them...'
# A 'clean' will remove nx
npm install
npm install --ignore-scripts
# TODO: Temp commented out
nx build "@storyteller/api"
@@ -1,3 +1,4 @@
export * from "./thumbnail-utils";
export * from "./media-format";
export * from "./referral";
export * from "./keyboard";
@@ -0,0 +1,16 @@
/**
* For an element with `role="button"`: Enter or Space clicks it, as they would a native button.
* Keys pressed on a control inside it are left to that control.
*/
export function clickOnEnterOrSpace(event: {
key: string;
preventDefault(): void;
target: unknown;
currentTarget: { click(): void };
}) {
if (event.target !== event.currentTarget) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.currentTarget.click();
}
}
@@ -251,6 +251,7 @@ export function WaveformAudioPlayer({
return (
<div
role="presentation"
ref={rootRef}
className={`flex items-center gap-2.5 ${compact ? "px-2.5 py-2" : "px-3 py-2.5"} ${className}`}
onClick={(event) => event.stopPropagation()}
@@ -273,6 +274,7 @@ export function WaveformAudioPlayer({
)}
{(waveState === "pending" || waveState === "failed") && (
<div
role="presentation"
className="absolute inset-0 flex cursor-pointer items-center gap-[1.5px]"
onClick={handleBarSeek}
>
@@ -135,7 +135,7 @@ export const ButtonDropdown = ({
)}
<div className="grow text-start">{option.label}</div>
<div className="ml-6 font-mono text-[11px] font-normal text-white/45">
{option.description && option.description}
{option.description}
</div>
{showSelected && (
<>
@@ -52,6 +52,7 @@ export const FolderColorRow: React.FC<FolderColorRowProps> = ({
"conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)",
}}
>
<span className="sr-only">Custom color</span>
<input
type="color"
value={
@@ -64,7 +64,6 @@ export const FolderNameDialog: React.FC<FolderNameDialogProps> = ({
if (e.key === "Enter") submit();
}}
className="w-full rounded-[3px] border border-ui-panel-border bg-ui-controls/40 px-3 py-1.5 text-sm text-base-fg outline-none focus:ring-1 focus:ring-primary/50"
autoFocus
/>
<div className="flex justify-end gap-2">
<Button
@@ -13,6 +13,7 @@ import {
isActionReminderOpen,
} from "@storyteller/ui-action-reminder-modal";
import { toast } from "@storyteller/ui-toaster";
import { clickOnEnterOrSpace } from "@storyteller/common";
type ModalMode = "select" | "view";
@@ -336,6 +337,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
{/* dropdown menu */}
{mode !== "select" && (
<div
role="presentation"
// Tile chrome stays under sticky page headers (e.g. the library
// filter bar at z-10): z-[2]/z-[1] keep the in-tile ordering
// (menu/checkbox above badges) without escaping the page layers.
@@ -376,6 +378,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
createPortal(
<>
<div
role="presentation"
className="fixed inset-0 z-[9998]"
onClick={() => setCtxMenu(null)}
onContextMenu={(e) => {
@@ -408,6 +411,11 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
{/* Bulk selection checkbox — top-left (only when a bulk handler is wired) */}
{mode !== "select" && onBulkSelectToggle && (
<div
role="checkbox"
aria-checked={bulkSelected}
aria-label={`Select ${item.label}`}
tabIndex={0}
onKeyDown={clickOnEnterOrSpace}
className={twMerge(
"absolute left-2 top-2 z-[2] flex h-5 w-5 items-center justify-center rounded-[3px] border-2 cursor-pointer transition-all duration-100",
bulkSelected
@@ -415,7 +423,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
: "border-white/60 bg-black/40 hover:border-white",
bulkSelectionMode
? "opacity-100"
: "opacity-0 group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100",
: "opacity-0 group-hover:opacity-100 focus-visible:opacity-100 [@media(pointer:coarse)]:opacity-100",
)}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
@@ -403,7 +403,7 @@ const SIDEBAR_TAG_LIMIT = 5;
const hashString = (s: string): number => {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
for (let i = 0; i < s.length; i++) h = Math.imul(Math.imul(h, 31) + s.charCodeAt(i), 1); // int32 wrap
return h;
};
@@ -948,7 +948,7 @@ export const GalleryModal = React.memo(
// couldn't see it — making the library button appear broken.
galleryModalDraggingUnder.value = false;
let cancelled = false;
(async () => {
void (async () => {
setUsernameError(false);
const name = await fetchCurrentUsername();
if (cancelled) return;
@@ -2710,7 +2710,6 @@ export const GalleryModal = React.memo(
}}
onBlur={handleConfirmRename}
className="text-lg font-semibold bg-transparent px-1.5 py-0 text-base-fg outline-none min-w-[6rem] caret-primary"
autoFocus
/>
) : (
<h2
@@ -2739,6 +2738,7 @@ export const GalleryModal = React.memo(
{folderMenuOpen && (
<>
<div
role="presentation"
className="fixed inset-0 z-[58]"
onClick={() => setFolderMenuOpen(false)}
/>
@@ -3573,6 +3573,7 @@ export const GalleryModal = React.memo(
<>
{/* Backdrop to close */}
<div
role="presentation"
className="fixed inset-0 z-[59]"
onClick={() => setBulkFolderPopoverOpen(false)}
/>
@@ -3716,6 +3717,7 @@ export const GalleryModal = React.memo(
createPortal(
<>
<div
role="presentation"
className="fixed inset-0 z-[9998]"
onClick={() => setContextMenu(null)}
onContextMenu={(e) => {
@@ -87,10 +87,10 @@ export function useMediaPromptTokens(
if (t) cleaned.push(t);
}
// Stable cache key for the effect's dep list, order-independent.
const key = [...new Set(cleaned)].sort().join(",");
const key = [...new Set(cleaned)].sort((a, b) => a.localeCompare(b)).join(",");
useEffect(() => {
if (key.length > 0) fetchMissing(key.split(","));
if (key.length > 0) void fetchMissing(key.split(","));
}, [key]);
const result = new Map<string, string>();
@@ -60,10 +60,10 @@ export function usePrompts(
}
// Stable cache key for the effect's dep list. Sorting keeps it stable when
// a poll returns the same set in a different order.
const key = [...new Set(cleaned)].sort().join(",");
const key = [...new Set(cleaned)].sort((a, b) => a.localeCompare(b)).join(",");
useEffect(() => {
if (key.length > 0) fetchMissing(key.split(","));
if (key.length > 0) void fetchMissing(key.split(","));
}, [key]);
const result = new Map<string, Prompts>();
@@ -8,6 +8,7 @@ import { useTutorialModalStore } from "./help-menu-store";
import { Button } from "@storyteller/ui-button";
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
import { getNewsPosts, markdownToHtml } from "@storyteller/markdown-content";
import { clickOnEnterOrSpace } from "@storyteller/common";
export type HelpMenuButtonProps = {
items?: TutorialItem[];
@@ -42,7 +43,7 @@ const NewsView = ({ onBack }: { onBack: () => void }) => {
console.warn("Failed to fetch news feed, using bundled content.", e);
}
};
fetchNews();
void fetchNews();
}, []);
const selectedPost = useMemo(
@@ -109,6 +110,9 @@ const NewsView = ({ onBack }: { onBack: () => void }) => {
<div className="flex flex-col gap-4 mt-2 h-[500px] overflow-y-auto pr-2 custom-scrollbar">
{items.map((item) => (
<div
role="button"
tabIndex={0}
onKeyDown={clickOnEnterOrSpace}
key={item.slug}
onClick={() => setSelectedSlug(item.slug)}
className="bg-white/5 hover:bg-white/[0.07] border border-white/15 hover:border-white/25 p-4 rounded-none cursor-pointer transition-colors group"
@@ -312,7 +316,7 @@ export function HelpMenuButton({
});
};
run();
void run();
return () => {
if (progressTimerRef.current) {
@@ -104,7 +104,7 @@ export const InputVector = ({
} as Record<string, string>;
const newValue =
parseFloat(newVector[ref.current?.name ?? ""]) + direction * increment;
Number.parseFloat(newVector[ref.current?.name ?? ""]) + direction * increment;
newVector[ref.current?.name ?? ""] = (
Math.round(newValue * 1000) / 1000
@@ -22,10 +22,10 @@ export const NumberInput: React.FC<NumberInputProps> = ({
const newValue = event.target.value;
if (
newValue === "" ||
(!isNaN(Number(newValue)) && Number(newValue) <= 100)
(!Number.isNaN(Number(newValue)) && Number(newValue) <= 100)
) {
setInputValue(newValue);
const numericValue = parseFloat(newValue);
const numericValue = Number.parseFloat(newValue);
if (newValue !== "") {
onChange(Math.round(numericValue * 100) / 100);
}
@@ -42,6 +42,7 @@ import {
formatAspectRatio,
formatDuration,
formatResolution,
clickOnEnterOrSpace,
} from "@storyteller/common";
import { TagsSection } from "./tags/TagsSection";
@@ -179,7 +180,7 @@ export function LightboxModal({
audioPlayerRef.current = null;
};
audioPlayerRef.current = audio;
audio.play();
void audio.play();
setPlayingAudioToken(token);
}
},
@@ -962,6 +963,16 @@ export function LightboxModal({
}
>
<div
role={isVideoRef ? "presentation" : "button"}
tabIndex={isVideoRef ? undefined : 0}
aria-label={
isAudio
? `${isPlayingThis ? "Pause" : "Play"} reference ${index + 1} audio`
: isVideoRef
? undefined
: `Preview reference ${index + 1}`
}
onKeyDown={clickOnEnterOrSpace}
className="relative aspect-square w-12 overflow-hidden border border-white/15 bg-white/5 transition-colors hover:border-white/40 group cursor-pointer"
onClick={() => {
if (isAudio) {
@@ -141,6 +141,7 @@ export function TagChipInput({
return (
<div className="relative">
<div
role="presentation"
className={`flex flex-wrap items-center gap-1.5 rounded-[3px] bg-ui-panel border border-ui-panel-border focus-within:border-primary/60 px-3 py-2.5 ${
disabled ? "" : "cursor-text"
}`}
@@ -83,7 +83,7 @@ export function TagsSection({
useEffect(() => {
if (currentUsername !== undefined) return;
let cancelled = false;
resolveSessionUsername().then((username) => {
void resolveSessionUsername().then((username) => {
if (!cancelled) setResolvedUsername(username);
});
return () => {
@@ -133,7 +133,7 @@ export function TagsSection({
}
ownTagsLoadedRef.current = true;
let cancelled = false;
(async () => {
void (async () => {
const all: TagDetails[] = [];
let cursor: string | undefined = undefined;
for (let page = 0; page < 50; page++) {
@@ -89,8 +89,9 @@ export const ArtCraftSignUp = ({
{isSignUp ? (
<>
<div className="space-y-2">
<label className={FIELD_LABEL}>Username</label>
<label htmlFor="signup-username" className={FIELD_LABEL}>Username</label>
<Input
id="signup-username"
name="username"
placeholder="Username"
required
@@ -99,8 +100,9 @@ export const ArtCraftSignUp = ({
/>
</div>
<div className="space-y-2">
<label className={FIELD_LABEL}>Email</label>
<label htmlFor="signup-email" className={FIELD_LABEL}>Email</label>
<Input
id="signup-email"
name="email"
type="email"
placeholder="you@example.com"
@@ -112,8 +114,9 @@ export const ArtCraftSignUp = ({
</>
) : (
<div className="space-y-2">
<label className={FIELD_LABEL}>Email or Username</label>
<label htmlFor="signup-username-or-email" className={FIELD_LABEL}>Email or Username</label>
<Input
id="signup-username-or-email"
name="usernameOrEmail"
placeholder="you@example.com or username"
required
@@ -124,9 +127,10 @@ export const ArtCraftSignUp = ({
)}
<div className="space-y-2">
<label className={FIELD_LABEL}>Password</label>
<label htmlFor="signup-password" className={FIELD_LABEL}>Password</label>
<div className="relative">
<Input
id="signup-password"
name="password"
type={showPassword ? "text" : "password"}
placeholder="Min. 8 characters"
@@ -147,9 +151,10 @@ export const ArtCraftSignUp = ({
{isSignUp && (
<div className="space-y-2">
<label className={FIELD_LABEL}>Confirm Password</label>
<label htmlFor="signup-confirm-password" className={FIELD_LABEL}>Confirm Password</label>
<div className="relative">
<Input
id="signup-confirm-password"
name="confirmPassword"
type={showConfirm ? "text" : "password"}
placeholder="Re-enter password"
@@ -104,7 +104,7 @@ export function LoginModal({
};
const handleDiscordJoin = () => {
window.open("https://discord.gg/75svZP2Vje", "_blank");
window.open("https://discord.gg/75svZP2Vje", "_blank", "noopener,noreferrer");
setShowDiscord(false);
setShowSuccess(true);
};
@@ -229,6 +229,7 @@ export function LoginModal({
leaveTo="opacity-0 scale-95"
>
<div
role="presentation"
className={`relative w-full ${loggedInUsername !== null ? "max-w-md" : "max-w-lg lg:max-w-5xl"}`}
onClick={(e) => e.stopPropagation()}
>
@@ -541,6 +541,7 @@ export const Modal = ({
return cloneElement(typedChild, {
children: (
<div
role="presentation"
style={{ cursor: "move", userSelect: "none" }}
onMouseDown={handleDragStart}
>
@@ -561,6 +562,7 @@ export const Modal = ({
return cloneElement(typedChildren, {
children: (
<div
role="presentation"
style={{ cursor: "move", userSelect: "none" }}
onMouseDown={handleDragStart}
>
@@ -626,8 +628,8 @@ export const Modal = ({
let minHeight = 240;
try {
const cs = window.getComputedStyle(modalRef.current);
const parsedMinW = parseFloat(cs.minWidth || "0");
const parsedMinH = parseFloat(cs.minHeight || "0");
const parsedMinW = Number.parseFloat(cs.minWidth || "0");
const parsedMinH = Number.parseFloat(cs.minHeight || "0");
if (!Number.isNaN(parsedMinW) && parsedMinW > 0)
minWidth = Math.max(minWidth, parsedMinW);
if (!Number.isNaN(parsedMinH) && parsedMinH > 0)
@@ -743,6 +745,7 @@ export const Modal = ({
return handles.map((h) => (
<div
role="presentation"
key={h.dir}
className={`${baseClass} ${h.className}`}
style={{ cursor: cursorMap[h.dir] }}
@@ -33,7 +33,7 @@ export const collectTags = (board: Board): string[] => {
board.itemOrder.forEach((id) => {
board.items[id]?.tags.forEach((t) => seen.add(t));
});
return Array.from(seen).sort();
return Array.from(seen).sort((a, b) => a.localeCompare(b));
};
const itemMatchesText = (it: BoardItem, q: string): boolean => {
@@ -8,6 +8,8 @@ import {
isActionReminderOpen,
showActionReminder,
} from "@storyteller/ui-action-reminder-modal";
import { clickOnEnterOrSpace } from "@storyteller/common";
import { v4 as uuidv4 } from "uuid";
export type { ImageBundle };
@@ -53,7 +55,7 @@ export const HistoryStack = ({
// This is used to force image reloads in different sessions
// and prevent fetching CORS-tainted images from cache
const sessionRandBuster = useRef(Math.random());
const sessionRandBuster = useRef(uuidv4());
const scrollRef = useRef<HTMLDivElement | null>(null);
const prevPendingCountRef = useRef<number>(0);
@@ -183,6 +185,9 @@ export const HistoryStack = ({
for removal. Hovering reveals a striped overlay and a
centered trash affordance. */}
<div
role="button"
tabIndex={0}
onKeyDown={clickOnEnterOrSpace}
className="group relative w-full cursor-pointer"
title={
tileCount > 1
@@ -308,6 +313,7 @@ export const HistoryStack = ({
/>
)}
<div
role="presentation"
className="absolute -right-0 -top-0 flex h-5 w-5 items-center justify-center rounded-bl-lg bg-red/50 opacity-0 transition-opacity hover:bg-red/80 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
@@ -50,6 +50,7 @@ import {
} from "./components/Model3DOverlay";
import { CameraIcon, Maximize2Icon, RefreshCwIcon } from "lucide-react";
import type { PageDrawAdapter } from "./adapter";
import { v4 as uuidv4 } from "uuid";
const PAGE_ID: ModelPage = ModelPage.Canvas2D;
@@ -857,7 +858,7 @@ const PageDraw = ({
const subscriberId: string =
crypto?.randomUUID?.() ??
`inpaint-${Date.now()}-${Math.random().toString(36).slice(2)}`;
`inpaint-${Date.now()}-${uuidv4()}`;
adapter.onEnqueueMeta?.({
prompt,
@@ -1040,7 +1041,7 @@ const PageDraw = ({
await tryFit();
};
autoFitCanvas();
void autoFitCanvas();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [baseImageBitmap, baseImageInfo?.isBlankCanvas]);
@@ -1501,7 +1501,7 @@ export const PaintSurface = ({
// Load the checkboard image for transparency background
useEffect(() => {
loadImageFromUrl(checkerboard).then(setCheckerImage);
void loadImageFromUrl(checkerboard).then(setCheckerImage);
}, []);
// Separate function to render transformers on their own layer
@@ -14,7 +14,7 @@ const ImagePreview: React.FC<ImagePreviewProps> = ({ image, onUpdate, onRemove }
const relativeY = clientY - rect.top;
// Calculate inverse value (1.0 at top, 0.0 at bottom)
const value = Math.max(0, Math.min(1, 1 - (relativeY / height)));
onUpdate(image.id, { weight: parseFloat(value.toFixed(1)) });
onUpdate(image.id, { weight: Number.parseFloat(value.toFixed(1)) });
}
};
@@ -61,7 +61,8 @@ const ImagePreview: React.FC<ImagePreviewProps> = ({ image, onUpdate, onRemove }
<X size={16} />
</button>
<div
<div
role="presentation"
ref={sliderRef}
className="absolute inset-0 bg-gradient-to-b from-transparent to-black bg-opacity-40 cursor-ns-resize"
onMouseDown={handleMouseDown}
@@ -122,7 +122,8 @@ export const ContextMenuContainer: React.FC<ContextMenuContainerProps> = ({
};
return (
<div
<div
role="presentation"
onClick={handleClick}
onContextMenu={handleContextMenu}
className={`w-full h-full relative z-[99999] ${className}`}
@@ -119,9 +119,9 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
if (colorToUse && colorToUse.startsWith("#")) {
// Convert hex to HSVA (simplified - might want to change to proper converter later)
const hex = colorToUse;
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const r = Number.parseInt(hex.slice(1, 3), 16);
const g = Number.parseInt(hex.slice(3, 5), 16);
const b = Number.parseInt(hex.slice(5, 7), 16);
// Convert RGB to HSV (simplified)
const max = Math.max(r, g, b);
@@ -89,6 +89,7 @@ export default function SplitPane({
</div>
<div
role="presentation"
className="w-1 cursor-col-resize select-none bg-white/20
transition hover:bg-white/30"
onMouseDown={beginDrag}
@@ -1011,9 +1011,7 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
const pastedLineNode: LineNode = {
...lineNode,
id: newId,
points: lineNode.points.map((point, index) =>
index % 2 === 0 ? point + offset : point + offset,
),
points: lineNode.points.map((point) => point + offset),
x: lineNode.x !== undefined ? lineNode.x + offset : undefined,
y: lineNode.y !== undefined ? lineNode.y + offset : undefined,
};
@@ -1131,7 +1129,10 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
const base64ToFile = (base64: string, filename: string): File => {
const arr = base64.split(",");
const mime = arr[0].match(/:(.*?);/)?.[1] || "image/png";
// data:<mime>;base64: the text between the first ":" and the ";" after it.
const start = arr[0].indexOf(":") + 1;
const end = arr[0].indexOf(";", start);
const mime = (start > 0 && end !== -1 && arr[0].slice(start, end)) || "image/png";
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
@@ -1167,7 +1168,7 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
console.error("Failed to restore image:", error);
}
};
loadImage();
void loadImage();
}
return node;
@@ -273,7 +273,7 @@ class DndAsset {
return;
}
const inspection = this.clipNodeNamesCache.get(item.media_id);
if (!inspection) {
if (inspection === undefined) {
// No inspection in flight (shouldn't happen — pointer-down starts
// one): add unvalidated, matching the ungated click-to-add path.
this.completeAnimationDrop(editor, item, target, undefined);
@@ -387,7 +387,7 @@ export const Stage3DBody = ({
useEffect(() => {
const handler = onImageDrop(
(item: GalleryItem, position: { x: number; y: number }) => {
(async () => {
void (async () => {
if (!editor) {
console.warn("Cannot drop asset: editor engine not ready");
return;
@@ -485,6 +485,7 @@ export const Stage3DBody = ({
<CompletionModal />
<div
role="presentation"
className="absolute left-0 top-0 w-full"
onClick={handleOverlayClick}
>
@@ -523,6 +524,7 @@ export const Stage3DBody = ({
</div>
<div
role="presentation"
className="absolute bottom-0 left-0 right-0"
onClick={handleOverlayClick}
>
@@ -14,7 +14,7 @@ export function toggleObjectVisibility(editor: Editor, uuid: string): void {
if (!obj) return;
const before = obj.visible;
editor.bus.emit(new OutlinerItemVisibilityToggledEvent(uuid));
editor.sceneManager?.hideObject(uuid);
void editor.sceneManager?.hideObject(uuid);
editor.history.record(
new VisibilityAction(editor, uuid, before, !before),
);
@@ -8,6 +8,7 @@ import { addClipToCharacter } from "../../../actions";
import { AssetType, ToastTypes } from "../../../enums";
import { usePageSceneStore } from "../../../PageSceneStore";
import type { MediaItem } from "../../../models/assets";
import { clickOnEnterOrSpace } from "@storyteller/common";
interface Props {
debug?: string;
@@ -110,6 +111,10 @@ export const ItemElement = ({ item }: Props) => {
)}
<div
// Only animations are added by a click; the rest are dragged in.
role={item.type === AssetType.ANIMATION ? "button" : "presentation"}
tabIndex={item.type === AssetType.ANIMATION ? 0 : undefined}
onKeyDown={clickOnEnterOrSpace}
className="pointer-events-none relative aspect-[16/12] w-full select-none overflow-hidden rounded-none border border-white/15 bg-brand-secondary-600 object-cover object-center transition-all group-hover:border-brand-primary"
onPointerDown={(event) => dragAndDrop.onPointerDown(event, item, editor)}
onClick={handleClick}
@@ -119,9 +119,9 @@ export const ControlPanelSceneObject = () => {
function localToEngine(xyz: Record<string, string>) {
return {
x: parseFloat(xyz.x),
y: parseFloat(xyz.y),
z: parseFloat(xyz.z),
x: Number.parseFloat(xyz.x),
y: Number.parseFloat(xyz.y),
z: Number.parseFloat(xyz.z),
};
}
function engineToLocal(xyz: XYZ) {
@@ -245,7 +245,7 @@ export const ControlPanelSceneObject = () => {
if (value === "" || value === "-" || value === ".") {
return true;
}
return !/^-?[0-9]*(.[0-9]*)?$/.test(value);
return !/^-?\d*(\.\d*)?$/.test(value);
});
const handlePositionChange = (xyz: Record<string, string>) => {
@@ -276,9 +276,9 @@ export const ControlPanelSceneObject = () => {
const handleUniformScaleChange = (scale: number) => {
const updatedScaleValues: Record<string, string> = {};
updatedScaleValues.x = (parseFloat(localScale.x) + scale).toString();
updatedScaleValues.y = (parseFloat(localScale.y) + scale).toString();
updatedScaleValues.z = (parseFloat(localScale.z) + scale).toString();
updatedScaleValues.x = (Number.parseFloat(localScale.x) + scale).toString();
updatedScaleValues.y = (Number.parseFloat(localScale.y) + scale).toString();
updatedScaleValues.z = (Number.parseFloat(localScale.z) + scale).toString();
handleScaleChange(updatedScaleValues);
};
@@ -93,7 +93,7 @@ export const DraggablePrecisionMutator = ({
}, [handleMouseMove, handleMouseUp, precisions]);
return (
<div className={classes} ref={divRef} onMouseDown={handleMouseDown}>
<div role="presentation" className={classes} ref={divRef} onMouseDown={handleMouseDown}>
{children}
</div>
)
@@ -4,6 +4,6 @@ export function objectMismatch(
) {
return Object.keys(inputObj).some(
(currentKey: string) =>
parseFloat(inputObj[currentKey]) !== refObj[currentKey],
Number.parseFloat(inputObj[currentKey]) !== refObj[currentKey],
);
}
@@ -17,13 +17,13 @@ function sanitizeNumericInput(input: string): number {
const integerPart = parts[0];
const decimalPart = parts[1];
const decimal = decimalPart !== "" ? parseFloat(`.${decimalPart}`) : 0.0;
const decimal = decimalPart !== "" ? Number.parseFloat(`.${decimalPart}`) : 0.0;
if (integerPart === undefined) {
return decimal;
}
const integer = parseInt(integerPart);
const integer = Number.parseInt(integerPart);
if (decimalPart === undefined) {
return integer;
@@ -45,7 +45,7 @@ export function EntranceDebugPanel() {
max={12}
step={0.25}
value={scale}
onChange={(e) => apply(parseFloat(e.target.value))}
onChange={(e) => apply(Number.parseFloat(e.target.value))}
className="w-full accent-primary"
/>
<div className="mt-2 flex justify-between tabular-nums text-white/50">
@@ -29,6 +29,7 @@ export const PoseModeSelector: React.FC<PoseModeSelectorProps> = () => {
return (
<div
role="presentation"
className="fixed left-1/2 top-32 flex -translate-x-1/2 transform items-center justify-center gap-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
@@ -19,7 +19,7 @@ export const DurationLabel = ({ className = "" }: { className?: string }) => {
}, [editing]);
const commit = () => {
const secs = parseFloat(value);
const secs = Number.parseFloat(value);
if (editor && Number.isFinite(secs)) setTimelineDuration(editor, secs);
setEditing(false);
};
@@ -74,6 +74,7 @@ export const MotionPopover = ({
return (
<div
role="presentation"
id="motion-popover"
className="border border-ui-panel-border bg-ui-controls pointer-events-auto absolute bottom-full z-50 mb-2 w-[180px] -translate-x-1/2 select-none rounded-none p-3"
style={{ left: `${Math.max(12, Math.min(88, leftPercent))}%` }}
@@ -25,6 +25,7 @@ export const TimelineBar = ({ readOnly = false }: { readOnly?: boolean }) => {
return (
<div
role="presentation"
className="border border-ui-panel-border bg-ui-controls flex w-full select-none items-center gap-3 rounded-none px-4 py-2 text-white"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
@@ -50,7 +51,7 @@ export const TimelineBar = ({ readOnly = false }: { readOnly?: boolean }) => {
onChange={(e) => {
if (!editor) return;
pauseTimeline(editor);
seekTimeline(editor, parseFloat(e.target.value));
seekTimeline(editor, Number.parseFloat(e.target.value));
}}
className="h-1 flex-1 cursor-pointer accent-white"
/>
@@ -194,6 +194,7 @@ export const TimelineClipRow = ({
<>
{/* click-away backdrop */}
<div
role="presentation"
className="fixed inset-0"
style={{ zIndex: 9998 }}
onClick={() => setPickerAnchor(null)}
@@ -316,6 +316,7 @@ export const TimelineEditor = () => {
return (
<div
role="presentation"
id="timeline-editor"
className="border border-ui-panel-border bg-ui-controls absolute bottom-4 left-1/2 w-[90vw] max-w-5xl -translate-x-1/2 select-none rounded-none p-3 text-white"
onMouseDown={(e) => e.stopPropagation()}
@@ -2,6 +2,7 @@ import { ReactNode, useEffect, useRef, useState } from "react";
import { EngineContext, setActiveEditor } from "./EngineContext";
import Editor from "../../engine/editor";
import { ensureAmmoLoaded } from "../../engine/ammoLoader";
import type { PageSceneAdapter } from "../../adapter";
import { getSceneGenerationMetaData } from "../../sceneMetadata";
import { usePageSceneStore } from "../../PageSceneStore";
@@ -61,6 +62,9 @@ export const EngineProvider = ({
// them to null on unmount).
if (!sceneContainer || !editorCanvas || !camViewCanvas) return undefined;
// Ammo.js WASM is a global side-effect script the physics path depends on; the singleton
// appends it to document.body once, however often the Editor is constructed.
void ensureAmmoLoaded();
const newEditor = new Editor(adapterRef.current);
newEditor.initialize({
sceneToken: sceneToken || "",
@@ -8,7 +8,7 @@
let ammoLoadPromise: Promise<void> | null = null;
export const ensureAmmoLoaded = (): Promise<void> => {
if (ammoLoadPromise) return ammoLoadPromise;
if (ammoLoadPromise !== null) return ammoLoadPromise;
ammoLoadPromise = new Promise<void>((resolve, reject) => {
if (typeof document === "undefined") {
@@ -31,7 +31,6 @@ import { ModalTransformController } from "./editor/ModalTransformController";
import Stats from "three/examples/jsm/libs/stats.module.js";
import { SparkRenderer } from "@sparkjsdev/spark";
import { usePageSceneStore } from "../PageSceneStore";
import { ensureAmmoLoaded } from "./ammoLoader";
import { EngineEventBus } from "./events/EngineEventBus";
import { EngineStoreBridge } from "./EngineStoreBridge";
import {
@@ -187,11 +186,6 @@ class Editor {
this.adapter = adapter;
this.can_initialize = true;
this.stats = new Stats();
// Ammo.js WASM is a global side-effect script the physics path
// depends on. The singleton ensures it's appended to document.body
// exactly once, even across Editor re-construction.
ensureAmmoLoaded();
// Version and name.
this.version = 2.0;
// Clock, scene and camera essentials.
@@ -667,9 +661,9 @@ class Editor {
};
if (!this.utils.isEmpty(cacheJson)) {
this.save_manager.loadCache(cacheJson).then(applyIfLoaded);
void this.save_manager.loadCache(cacheJson).then(applyIfLoaded);
} else if (!this.utils.isEmpty(sceneToken)) {
this.save_manager.loadScene(sceneToken).then(applyIfLoaded);
void this.save_manager.loadScene(sceneToken).then(applyIfLoaded);
} else {
this.adapter.onSceneTitleChange?.({
title: "Untitled New Scene",
@@ -1065,7 +1059,7 @@ class Editor {
}
this.lastFrameTime = frameTime;
this.renderSingleFrame();
void this.renderSingleFrame();
}
startRenderLoop() {
@@ -151,7 +151,7 @@ export class ModalTransformController {
}
}
this.target.position.copy(this.startPos).add(delta);
this.editor.renderScene();
void this.editor.renderScene();
}
private axisVector(axis: "x" | "y" | "z"): THREE.Vector3 {
@@ -190,7 +190,7 @@ export class ModalTransformController {
private cancel() {
if (this.target) this.target.position.copy(this.startPos);
this.editor.renderScene();
void this.editor.renderScene();
this.cleanup();
}
@@ -137,19 +137,19 @@ export class SelectionBridge {
version: String(this.engine.version),
objectVectors: {
position: {
x: parseFloat(pos.x.toFixed(2)),
y: parseFloat(pos.y.toFixed(2)),
z: parseFloat(pos.z.toFixed(2)),
x: Number.parseFloat(pos.x.toFixed(2)),
y: Number.parseFloat(pos.y.toFixed(2)),
z: Number.parseFloat(pos.z.toFixed(2)),
},
rotation: {
x: parseFloat(THREE.MathUtils.radToDeg(rot.x).toFixed(2)),
y: parseFloat(THREE.MathUtils.radToDeg(rot.y).toFixed(2)),
z: parseFloat(THREE.MathUtils.radToDeg(rot.z).toFixed(2)),
x: Number.parseFloat(THREE.MathUtils.radToDeg(rot.x).toFixed(2)),
y: Number.parseFloat(THREE.MathUtils.radToDeg(rot.y).toFixed(2)),
z: Number.parseFloat(THREE.MathUtils.radToDeg(rot.z).toFixed(2)),
},
scale: {
x: parseFloat(scale.x.toFixed(6)),
y: parseFloat(scale.y.toFixed(6)),
z: parseFloat(scale.z.toFixed(6)),
x: Number.parseFloat(scale.x.toFixed(6)),
y: Number.parseFloat(scale.y.toFixed(6)),
z: Number.parseFloat(scale.z.toFixed(6)),
},
},
}),
@@ -259,9 +259,9 @@ export class MouseControls {
if (event.button !== 0 && this.camera) {
const camera_pos = new THREE.Vector3(
parseFloat(this.camera.position.x.toFixed(2)),
parseFloat(this.camera.position.y.toFixed(2)),
parseFloat(this.camera.position.z.toFixed(2)),
Number.parseFloat(this.camera.position.x.toFixed(2)),
Number.parseFloat(this.camera.position.y.toFixed(2)),
Number.parseFloat(this.camera.position.z.toFixed(2)),
);
this.camera_last_pos.copy(camera_pos);
}
@@ -408,9 +408,9 @@ export class MouseControls {
}
const camera_pos = new THREE.Vector3(
parseFloat(this.camera.position.x.toFixed(2)),
parseFloat(this.camera.position.y.toFixed(2)),
parseFloat(this.camera.position.z.toFixed(2)),
Number.parseFloat(this.camera.position.x.toFixed(2)),
Number.parseFloat(this.camera.position.y.toFixed(2)),
Number.parseFloat(this.camera.position.z.toFixed(2)),
);
if (this.camera_last_pos.equals(new THREE.Vector3(0, 0, 0))) {
this.camera_last_pos.copy(camera_pos);
@@ -77,7 +77,7 @@ export class LoadingPlaceHolderManager {
if (result === undefined) {
return;
}
result.destroyWithScene(this.scene);
void result.destroyWithScene(this.scene);
this.messages.delete(id);
}
@@ -91,7 +91,6 @@ class Scene {
) {
this.version = version;
this.name = name;
this.gridHelper;
this.scene = new THREE.Scene();
this.hot_items = [];
@@ -112,7 +111,7 @@ class Scene {
initialize() {
this.scene = new THREE.Scene();
this.placeholder_manager = new LoadingPlaceHolderManager(this.scene);
this.placeholder_manager.initialize();
void this.placeholder_manager.initialize();
this._createGrid();
this._create_base_lighting();
@@ -1026,21 +1025,20 @@ class Scene {
onComplete: () => void,
signal?: AbortSignal,
): Promise<GLTF> {
return new Promise<GLTF>(async (resolve, reject) => {
let buffer;
try {
// Host-supplied CORS-bypassed binary fetch (FetchProxy under
// Tauri; plain fetch under web hosts that allow it).
buffer = await (
await this.deps.fetchAsset(media_url, { signal })
).arrayBuffer();
} catch (error) {
if ((error as { name?: string })?.name !== "AbortError") {
console.error("load GLB from Tauri error:", error);
}
reject(error);
return;
let buffer: ArrayBuffer;
try {
// Host-supplied CORS-bypassed binary fetch (FetchProxy under
// Tauri; plain fetch under web hosts that allow it).
buffer = await (
await this.deps.fetchAsset(media_url, { signal })
).arrayBuffer();
} catch (error) {
if ((error as { name?: string })?.name !== "AbortError") {
console.error("load GLB from Tauri error:", error);
}
throw error;
}
return new Promise<GLTF>((resolve, reject) => {
const glbLoader = new GLTFLoader();
glbLoader.parse(
buffer,
@@ -1068,30 +1066,23 @@ class Scene {
onComplete: () => void,
signal?: AbortSignal,
): Promise<SplatMesh> {
return new Promise(async (resolve, reject) => {
let buffer;
try {
// Host-supplied CORS-bypassed binary fetch (FetchProxy under
// Tauri; plain fetch under web hosts that allow it).
buffer = await (
await this.deps.fetchAsset(media_url, { signal })
).arrayBuffer();
} catch (error) {
if ((error as { name?: string })?.name !== "AbortError") {
console.error("load Splat from Tauri error:", error);
}
reject(error);
return;
let buffer: ArrayBuffer;
try {
// Host-supplied CORS-bypassed binary fetch (FetchProxy under
// Tauri; plain fetch under web hosts that allow it).
buffer = await (
await this.deps.fetchAsset(media_url, { signal })
).arrayBuffer();
} catch (error) {
if ((error as { name?: string })?.name !== "AbortError") {
console.error("load Splat from Tauri error:", error);
}
new SplatMesh({
fileBytes: buffer,
onLoad: (mesh) => {
resolve(mesh);
onComplete();
},
});
});
throw error;
}
const mesh = new SplatMesh({ fileBytes: buffer });
await mesh.initialized;
onComplete();
return mesh;
}
/**
@@ -32,15 +32,19 @@ export type SceneObject = {
};
export interface SceneManagerAPI {
create(media_token: string, name: string, position: THREE.Vector3): void;
retrieve(object_uuid: string): void;
delete(object_uuid: string): void;
create(
media_token: string,
name: string,
position: THREE.Vector3,
): Promise<THREE.Object3D | undefined>;
retrieve(object_uuid: string): Promise<THREE.Object3D | undefined>;
delete(object_uuid: string): Promise<void>;
update(
object_uuid: string,
position: THREE.Vector3,
rotation: THREE.Euler,
scale: THREE.Vector3,
): void;
): Promise<void>;
selected(): void;
render_outliner(timeline_characters: { [key: string]: ClipGroup }): void;
select_object(id: string): void;
@@ -24,6 +24,7 @@ import {
} from "lucide-react";
import { Model, ModelInfo } from "@storyteller/model-list";
import { Tooltip } from "@storyteller/ui-tooltip";
import { clickOnEnterOrSpace } from "@storyteller/common";
interface PortalTooltipProps {
children: React.ReactElement;
@@ -249,6 +250,7 @@ function InfoHint({ content }: { content: ReactNode }) {
{open &&
createPortal(
<div
role="presentation"
// Body-portaled, so inside a Radix modal (promptbox focus mode)
// taps here must not count as outside clicks on the dialog.
data-modal-outside-safe=""
@@ -351,6 +353,10 @@ function RichListRow({
}: RichListRowProps) {
return (
<div
role="button"
aria-disabled={item.disabled || undefined}
tabIndex={0}
onKeyDown={clickOnEnterOrSpace}
data-selected={item.selected ? "true" : undefined}
onClick={() => {
if (!item.disabled) onClick?.();
@@ -602,6 +608,7 @@ function SubmenuFlyout({
return (
<>
<div
role="presentation"
ref={triggerRef}
onMouseEnter={scheduleOpen}
onMouseLeave={scheduleClose}
@@ -1277,6 +1284,10 @@ export const PopoverMenu = ({
const itemRow = (
<div
role="button"
aria-disabled={item.disabled || undefined}
tabIndex={0}
onKeyDown={clickOnEnterOrSpace}
data-selected={
item.selected ? "true" : undefined
}
@@ -170,7 +170,7 @@ export function useCurrency(): UseCurrencyResult {
useEffect(() => {
let cancelled = false;
fetchRates().then((r) => {
void fetchRates().then((r) => {
if (!cancelled) {
setRates(r);
setRatesLoaded(true);

Some files were not shown because too many files have changed in this diff Show More