Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 9fb207aee9 Bump VectorCraft downloads to 0.5.0
The other Crafting Apps are already on their latest releases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:09:27 +07:00
BFlatandClaude Opus 5.5 d950e57352 Rename PrintCraft to PdfCraft on the Crafting Apps pages (#57)
## What

The `storytold/printcraft` repo is now
[`storytold/pdfcraft`](https://github.com/storytold/pdfcraft), and every
Crafting Apps README already links `getartcraft.com/apps/pdfcraft`,
which didn't exist on the site until now. This renames the app on the
site.

- **Slug and name:** `/apps/pdfcraft`, shown as "PdfCraft" in the nav,
footer, `/apps` hub, sitemap, press kit and JSON-LD. Copy, category and
theme colour are unchanged; only the name changes.
- **Legacy links redirect permanently (308):**
  - `/apps/printcraft` → `/apps/pdfcraft`
- `/images/apps/printcraft/<file>` → `/images/apps/pdfcraft/<file>`, so
share images already cached by social sites keep loading.
- **Images:** moved to `public/images/apps/pdfcraft/`. The four
screenshots and the share image were rebuilt from the README's renamed
`pdfcraft-*.png` renders, which are slightly newer than the site's (they
show the in-app Discord button). The "Full size" and press-kit links had
been pointing at the old `printcraft-*.png` names, which no longer
exist; they now point at the new files.
- **Downloads:** v0.2.1 was published before the rename, so its files
are still called `printcraft-0.2.1-*`. `CraftRelease` gains an optional
`fileSlug` for this, and PdfCraft sets it to `printcraft`. Once a
release ships with `pdfcraft-*` names, removing that one line switches
it over.

## Verification
Rendered locally:
- `/apps/printcraft` and the old image URLs return 308 to their PdfCraft
equivalents. Following the redirect lands on the PdfCraft page with its
theme.
- `/apps/pdfcraft`: the title, canonical URL and `og:image` use
pdfcraft. All 17 download links resolve directly on
`storytold/pdfcraft`, without going through GitHub's redirect.
- The sitemap lists `/apps/pdfcraft`, and `/`, `/apps` and `/press-kit`
show no "PrintCraft". All press-kit links for PdfCraft return 200.
- `tsc` shows no errors in the changed files. The errors that remain are
already on `main`.

## Note
The screenshots still show "PrintCraft" inside the app (the window tab
and the sample PDF), because the PdfCraft repo renamed the image files
without re-rendering them. They'll update once the README screenshots
are re-captured.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:56:47 +07:00
BFlatandClaude Opus 5.5 652146ad96 Refresh VectorCraft screenshots and bump Crafting App downloads (#56)
## What

### VectorCraft screenshots
The images were regenerated from the latest renders in VectorCraft's
README
([storytold/vectorcraft@1c3b7b1](https://github.com/storytold/vectorcraft/commit/1c3b7b16)).
The site's previous hero still showed the old DrawCraft UI.

- `hero`, `ribbons`, `bezier` and `sheet` replaced, and a new
`perspective` shot added for the Perspective Grid. They follow the
README's order.
- Captions and alt text updated to match what each new shot shows.
- `og.jpg` share image rebuilt from the new hero, composed the same way
as before: a centred crop with a 144px icon in the bottom-left corner.
- Encoding matches the existing files (1600×1000 WebP q82, JPEG q85), so
the files are about the same size, 50–94 KB each.

### Gallery layout
`AppGallery` used to show the first shot after the hero at full width
and pair the rest. With an even number of shots, that left an empty cell
at the end. Now the first shot only goes full width when the count is
odd. VectorCraft's four shots form a 2×2 grid, like the README's "A look
around" table. All other apps have three shots, so their layout is
unchanged.

### Download versions
| App | Before | After |
|---|---|---|
| PhotoCraft | 0.2.0 | **0.3.0** |
| VectorCraft | 0.3.1 | **0.4.0** |
| EffectCraft | 0.3.1 | **0.4.0** |
| FilmCraft, LightCraft, PrintCraft, DesignCraft | 0.2.1 | 0.2.1 (still
latest) |

These three releases add **Windows on ARM** builds (`windows-arm64.msi`,
`windows-arm64-portable.zip`). They're now listed in the Windows card
for those apps through a `WITH_WINDOWS_ARM64` asset list. The main
Windows button stays on x64, because browsers on Windows on ARM
generally report themselves as x64.

## Verification
- Every expected asset exists in each release.
- Rendered all seven `/apps/<slug>` pages locally and requested every
GitHub download and release link. All of them resolve, and the "Version"
label shows the new versions.
- Checked the VectorCraft gallery in the browser: a 2×2 grid with all
images loading.
- `tsc` shows no errors in the changed files. The errors that remain are
already on `main` (missing local Playwright types and
`media-viewer-3d.tsx`).

## Not in this PR
- **`storytold/printcraft` has been renamed to `storytold/pdfcraft`.**
PrintCraft links still work through GitHub's redirect. Renaming the app
on the site is a separate change.
- Releases also ship FreeBSD tarballs, and PhotoCraft also ships Flatpak
and zsync files. The site doesn't list these.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:51:45 +07:00
dafe769cda PageScene: dispose GPU assets on webapp (#55)
Targets PageScene and other 3D viewers, handles uniforms and textures,
amongst other general editor mount improvements

---------

Co-authored-by: heojunfo <kleague3@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 17:58:27 -07:00
BFlatandClaude Opus 5.5 ad9b10ba94 Bump Crafting App downloads to their latest releases (#54)
## What

Updates the `/apps/<slug>` download links to each app's latest GitHub
release:

| App | Before | After |
|---|---|---|
| VectorCraft | 0.2.0 | **0.3.1** |
| EffectCraft | 0.2.0 | **0.3.1** |
| FilmCraft | 0.2.0 | **0.2.1** |
| LightCraft | 0.2.0 | **0.2.1** |
| PrintCraft | 0.2.0 | **0.2.1** |
| DesignCraft | 0.2.0 | **0.2.1** |
| PhotoCraft | 0.2.0 | 0.2.0 (still its latest) |

`CRAFTING_APPS_LATEST_VERSION` becomes `0.2.1`, the most common version.
PhotoCraft, VectorCraft and EffectCraft are pinned with
`craftRelease(...)`, following the file's documented convention.

## Verification

- Compared each release's asset list against `CRAFT_RELEASE_ASSETS`:
every expected file exists.
- Rendered all seven app pages locally and requested every GitHub
download/release link (34 per page): all return 200/302.
- The "Version" label and JSON-LD `softwareVersion` show the new
versions.

## Note

PrintCraft 0.2.1 also ships `printcraft-0.2.1-freebsd-x86_64.tar.gz`,
which the site doesn't list. That's left for a follow-up if we want to
offer it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:38:45 +07:00
BFlatandClaude Opus 5.5 3880127f68 Add Google Analytics to the marketing site (#50)
## What

Adds GA4 to `artcraft-website-next`, covering the whole marketing site.

- `src/components/analytics.tsx` loads gtag.js (`afterInteractive`) and
installs one delegated click listener. It's mounted once in the root
layout.
- `src/lib/analytics.ts` holds the typed event catalogue,
`trackEvent()`, `trackAttrs()` (data attributes that work in server
components) and the link classifier.
- Analytics is **off unless `NEXT_PUBLIC_GA_MEASUREMENT_ID` is set**, so
local dev and deploy previews stay out of the reports.

## Why

The new site had no analytics. This gives us download, signup and
checkout funnels, plus CTA performance by page section.

## Events

GA4 Enhanced Measurement handles page views (App Router history
changes), scrolls, outbound clicks and common file downloads. On top of
that:

| Event | Trigger |
|---|---|
| `app_download` | GitHub release asset clicks (ArtCraft + Crafting
Apps): app, version, platform, file |
| `webapp_click` | Links to app.getartcraft.com: destination path |
| `cta_click` | Other `Button` links (tagged `data-cta`) |
| `app_select` | App cards and icons on `/apps` and sibling cards on app
pages: app name |
| `social_click`, `contact_click` | `SOCIAL_LINKS` hosts, mailto / copy
email |
| `begin_checkout` | Plan selection (`signup`/`subscribe`/`switch`) and
credit packs, with GA4 ecommerce `items` |
| `billing_cadence_change`, `view_credit_packs`, `manage_subscription` |
Pricing actions |
| `prompt_submit`, `video_generation` | H3 promptbox (`logged_in`;
complete/failed/timeout) |
| `sign_up`, `login` | Auth gate success |
| `generate_lead` | Beta form submission (honeypot hits excluded) |
| `video_start`, `share`, `copy_command`, `copy_prompt`,
`media_download`, `press_kit_download` | Video facades, share bars, copy
buttons, media/press downloads |

Every delegated click event also carries `link_location`: nav, footer,
modal, or the enclosing `section[id]`.

## Reviewer notes

- `Button` now renders `data-cta=""` on its anchor variants. There is no
visual change.
- `PricingTable.choosePlan` now branches on the computed `checkoutType`
rather than repeating the `user`/`hasActivePlan` conditions. The
behaviour is unchanged.
- `AuthGateModal` forms now return early on error instead of using
if/else, so the success event goes on the success path.

## Deploy

Set `NEXT_PUBLIC_GA_MEASUREMENT_ID` in Netlify, **Production context
only**, then redeploy. The ID is inlined at build time. This is
documented in `netlify.toml`.

After deploying:
- Mark key events in GA: `app_download`, `begin_checkout`, `sign_up`,
`generate_lead`, `webapp_click`.
- Register custom dimensions (e.g. `link_location`, `app_name`,
`platform`, `destination`, `checkout_type`) so the params show in
reports.

## Testing

Ran the dev server with a dummy measurement ID and checked `dataLayer`
on these pages:
- `/`: nav, footer, CTA, social, mailto and YouTube events.
- `/download`: Windows and macOS installers.
- `/apps/photocraft`: Linux downloads, share, copy command, GitHub link,
and `app_select` on the sibling cards (also checked on the `/apps` icon
row and lineup cards).
- `/pricing`: cadence toggle.
- `/minimax-h3`: logged-out `prompt_submit` and example `video_start`.

Not exercised in the browser: the beta form (submitting would post a
real Google Form entry), signup/login, checkout, the credits modal, the
media page, the press kit, email copy and manage plan.

`tsc` shows no new errors. Existing errors in `media-viewer-3d.tsx` and
the Playwright test files are unchanged. The production build was not
run.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:00:50 +07:00
62 changed files with 1467 additions and 156 deletions

No files matched your search

@@ -4,6 +4,10 @@
# Manage the build command and publish directory here; leave those UI fields empty.
# The Next.js runtime handles routing, the /cdn-media proxy rewrite, and the
# legacy-route redirects declared in next.config.ts. Other media is in public/.
#
# Google Analytics: set NEXT_PUBLIC_GA_MEASUREMENT_ID (G-XXXXXXXXXX) in the
# Netlify UI, scoped to the Production context so deploy previews don't
# report. It is inlined at build time, so redeploy after changing it.
[build]
command = "npm run build"
@@ -32,6 +32,18 @@ const nextConfig: NextConfig = {
destination: "/apps/vectorcraft",
permanent: true,
},
// PrintCraft was renamed PdfCraft. Old page links and share images
// (cached by social sites) follow it.
{
source: "/apps/printcraft",
destination: "/apps/pdfcraft",
permanent: true,
},
{
source: "/images/apps/printcraft/:file",
destination: "/images/apps/pdfcraft/:file",
permanent: true,
},
// Legacy share-link form (`/media?media=<token>`) from the Vite site;
// the canonical route is /media/<token>. Mirrored in netlify.toml so
// the edge answers it before the Next runtime.
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 78 KiB

@@ -22,6 +22,7 @@ import { AppIcon, ColorAccent, appThemeClass } from "@/components/apps/app-wordm
import { PageHeader } from "@/components/page/page-header";
import RevealManager from "@/components/reveal-manager";
import { Button } from "@/components/ui";
import { trackAttrs } from "@/lib/analytics";
import {
CRAFTING_APPS,
CRAFTING_APPS_COUNT_WORD,
@@ -108,6 +109,7 @@ export default function CraftingAppsPage() {
key={app.slug}
href={craftAppPath(app)}
className={`group flex w-18 flex-col items-center gap-2 sm:w-20 ${appThemeClass(app)}`}
{...trackAttrs("app_select", { app_name: app.slug })}
>
<AppIcon
app={app}
@@ -321,14 +321,14 @@ html[data-ruler="on"]::-webkit-scrollbar {
.app-vectorcraft { --app: #e8573f; --app-ink: #b83a24; }
.app-filmcraft { --app: #8b5cf6; --app-ink: #6a3fd6; }
.app-lightcraft { --app: #f2a516; --app-ink: #8a5800; }
.app-printcraft { --app: #12a58a; --app-ink: #0a7563; }
.app-pdfcraft { --app: #12a58a; --app-ink: #0a7563; }
.app-effectcraft { --app: #e0368f; --app-ink: #b0206c; }
.app-designcraft { --app: #7bb51c; --app-ink: #457008; }
:root[data-theme="dark"] .app-photocraft { --app-ink: #7fb0ff; }
:root[data-theme="dark"] .app-vectorcraft { --app-ink: #ff8a70; }
:root[data-theme="dark"] .app-filmcraft { --app-ink: #b69cff; }
:root[data-theme="dark"] .app-lightcraft { --app-ink: #ffc14d; }
:root[data-theme="dark"] .app-printcraft { --app-ink: #3fd6b8; }
:root[data-theme="dark"] .app-pdfcraft { --app-ink: #3fd6b8; }
:root[data-theme="dark"] .app-effectcraft { --app-ink: #ff7ab8; }
:root[data-theme="dark"] .app-designcraft { --app-ink: #a6e04a; }
@media (prefers-color-scheme: dark) {
@@ -336,7 +336,7 @@ html[data-ruler="on"]::-webkit-scrollbar {
:root:not([data-theme="light"]) .app-vectorcraft { --app-ink: #ff8a70; }
:root:not([data-theme="light"]) .app-filmcraft { --app-ink: #b69cff; }
:root:not([data-theme="light"]) .app-lightcraft { --app-ink: #ffc14d; }
:root:not([data-theme="light"]) .app-printcraft { --app-ink: #3fd6b8; }
:root:not([data-theme="light"]) .app-pdfcraft { --app-ink: #3fd6b8; }
:root:not([data-theme="light"]) .app-effectcraft { --app-ink: #ff7ab8; }
:root:not([data-theme="light"]) .app-designcraft { --app-ink: #a6e04a; }
}
@@ -9,6 +9,7 @@ import SiteNav from "@/components/site-nav";
import SiteFooter from "@/components/site-footer";
import IntroConductor from "@/components/intro-conductor";
import LandingContext from "@/components/landing-context";
import Analytics from "@/components/analytics";
import MotionProvider from "@/components/motion-provider";
import ScrollRuler from "@/components/ruler/scroll-ruler";
import TunerPanel from "@/components/dev/tuner-panel";
@@ -138,6 +139,7 @@ export default function RootLayout({
</MotionProvider>
<TunerPanel />
<LandingContext />
<Analytics />
</body>
</html>
);
@@ -0,0 +1,41 @@
"use client";
import { useEffect } from "react";
import Script from "next/script";
import { GA_MEASUREMENT_ID, trackClick } from "@/lib/analytics";
// Loads gtag.js after hydration and installs the one delegated click
// listener behind the site's link and data-track events (lib/analytics.ts).
// Renders nothing when no measurement id is configured.
export default function Analytics() {
useEffect(() => {
if (!GA_MEASUREMENT_ID) return;
// Capture phase: runs before click-to-play facades swap themselves out,
// and before any handler can stop propagation. auxclick covers
// middle-click "open in new tab".
const onClick = (event: MouseEvent) => {
if (event.type === "auxclick" && event.button !== 1) return;
trackClick(event.target);
};
document.addEventListener("click", onClick, true);
document.addEventListener("auxclick", onClick, true);
return () => {
document.removeEventListener("click", onClick, true);
document.removeEventListener("auxclick", onClick, true);
};
}, []);
if (!GA_MEASUREMENT_ID) return null;
return (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`}
strategy="afterInteractive"
/>
<Script id="ga-init" strategy="afterInteractive">
{`window.dataLayer=window.dataLayer||[];window.gtag=function(){dataLayer.push(arguments);};gtag("js",new Date());gtag("config",${JSON.stringify(GA_MEASUREMENT_ID)});`}
</Script>
</>
);
}
@@ -2,6 +2,7 @@ import Link from "next/link";
import { ArrowUpRightIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { Badge } from "@/components/ui";
import { trackAttrs } from "@/lib/analytics";
import {
craftAppName,
craftAppPath,
@@ -30,6 +31,7 @@ export default function AppCard({
<Link
href={craftAppPath(app)}
data-reveal
{...trackAttrs("app_select", { app_name: app.slug })}
className={twMerge(
"group relative flex h-full flex-col bg-bg",
appThemeClass(app),
@@ -6,6 +6,7 @@ import DiscordButton from "@/components/discord-button";
import { GitHubIcon } from "@/components/icons";
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
import { Badge, Button, CopyButton } from "@/components/ui";
import { trackAttrs } from "@/lib/analytics";
import {
CRAFTING_APPS,
craftAppBuildCommand,
@@ -31,11 +32,12 @@ import { ColorAccent } from "./app-wordmark";
const CELL_HEADING_CLASSES =
"mt-4 font-display text-3xl font-medium leading-[1.05] tracking-[-0.03em] text-ink-strong sm:text-4xl";
// The hero carries shot 01. Window captures lead with a full-width shot,
// then pair; portrait pages (layout spreads) sit four across.
// The hero carries shot 01. Window captures pair up, and an odd one out
// leads at full width; portrait pages (layout spreads) sit four across.
export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
const [, ...shots] = app.shots;
const portrait = shots.every((shot) => shot.portrait);
const leadFullWidth = !portrait && shots.length % 2 === 1;
return (
<SectionShell id="gallery">
<SectionEyebrow
@@ -54,7 +56,7 @@ export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
<figure
key={shot.file}
data-reveal
className={twMerge("bg-bg", !portrait && i === 0 && "md:col-span-2")}
className={twMerge("bg-bg", leadFullWidth && i === 0 && "md:col-span-2")}
>
<a
href={craftShotSourceUrl(app, shot)}
@@ -203,6 +205,7 @@ export function AppGetIt({ app, index }: { app: CraftApp; index: string }) {
value={command}
variant="ghost"
className="h-9 border-l border-line px-3"
{...trackAttrs("copy_command", { app_name: app.slug })}
/>
</div>
<pre className="overflow-x-auto p-4 font-mono text-[13px] leading-relaxed text-ink">
@@ -1,5 +1,6 @@
import { twMerge } from "tailwind-merge";
import { Button, CopyButton } from "@/components/ui";
import { trackAttrs } from "@/lib/analytics";
// Share strip: copy-link plus intent links for the networks our audience
// posts to. Hairline cells (gap-px over the line color) so it wraps cleanly
@@ -40,6 +41,7 @@ export default function ShareBar({
text: string;
className?: string;
}) {
const shareParams = { content_type: "page", item_id: new URL(url).pathname };
return (
<div
className={twMerge(
@@ -56,6 +58,7 @@ export default function ShareBar({
copiedLabel="Link copied"
variant="ghost"
className={CELL_CLASSES}
{...trackAttrs("share", { method: "copy_link", ...shareParams })}
/>
{TARGETS.map((target) => (
<Button
@@ -66,6 +69,7 @@ export default function ShareBar({
aria-label={`Share on ${target.name}`}
variant="ghost"
className={CELL_CLASSES}
{...trackAttrs("share", { method: target.name.toLowerCase(), ...shareParams })}
>
{target.name}
</Button>
@@ -4,6 +4,7 @@ import { useState, type FormEvent } from "react";
import { CheckIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { Button, Input, Label } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { webappUrl } from "@/lib/links";
// Submissions go to a Google Form whose responses land in a Google Sheet.
@@ -83,6 +84,7 @@ export default function BetaForm() {
// Google Forms sends no CORS headers, so this is fire-and-forget: the
// response is opaque and "request completed" counts as success.
await fetch(FORM_RESPONSE_URL, { method: "POST", mode: "no-cors", body });
trackEvent("generate_lead", { form_name: "beta", user_type: userType ?? "" });
setSubmitted(true);
} catch {
setSubmitError(
@@ -3,6 +3,7 @@
import { useState, type FormEvent } from "react";
import { CheckIcon, EyeIcon, EyeOffIcon } from "lucide-react";
import { Button, Input, Modal } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { login, signup } from "@/lib/api";
import {
getLandingUrl,
@@ -118,8 +119,9 @@ function SignupForm({
maybeReferralCode: getReferralCode(),
});
setLoading(false);
if (result.success) onSuccess();
else setError(result.errorMessage || "Failed to create account");
if (!result.success) return setError(result.errorMessage || "Failed to create account");
trackEvent("sign_up", { method: "email", signup_source: signupSource });
onSuccess();
};
return (
@@ -162,8 +164,9 @@ function LoginForm({ onSuccess }: { onSuccess: () => void }) {
setLoading(true);
const result = await login({ usernameOrEmail, password });
setLoading(false);
if (result.success) onSuccess();
else setError(result.errorMessage || "Invalid credentials");
if (!result.success) return setError(result.errorMessage || "Invalid credentials");
trackEvent("login", { method: "email" });
onSuccess();
};
return (
@@ -3,6 +3,7 @@
import { useState } from "react";
import { PlayIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { trackAttrs } from "@/lib/analytics";
import type { ExampleVideo } from "@/lib/campaign-data";
// Click-to-play facades: the mp4s (several MB each) are only requested
@@ -69,6 +70,11 @@ function ExampleCard({
onClick={() => setActive(true)}
aria-label={`Play example: ${example.label}`}
className="group absolute inset-0 h-full w-full"
{...trackAttrs("video_start", {
video_provider: "self_hosted",
video_title: example.label,
video_url: example.src,
})}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
@@ -9,6 +9,7 @@ import {
XIcon,
} from "lucide-react";
import { Button, Select, Switch } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { generateVideo, getVideoJob } from "@/lib/api";
import { H3, H3_GATE_PERKS, H3_SAMPLE_PROMPTS } from "@/lib/campaign-data";
import { webappUrl } from "@/lib/links";
@@ -101,6 +102,7 @@ export default function H3PromptBox() {
setSubmitting(false);
if (!result.success) {
patch(id, { status: "failed", error: result.errorMessage });
trackEvent("video_generation", { model: H3.modelId, status: "failed" });
return;
}
@@ -114,12 +116,15 @@ export default function H3PromptBox() {
if (job.status === "complete") {
patch(id, { status: "complete", videoUrl: job.videoUrl });
stops.current.delete(id);
trackEvent("video_generation", { model: H3.modelId, status: "complete" });
} else if (job.status === "failed") {
patch(id, { status: "failed", error: job.error });
stops.current.delete(id);
trackEvent("video_generation", { model: H3.modelId, status: "failed" });
} else if (attempts >= POLL_MAX_ATTEMPTS) {
patch(id, { status: "failed", error: "Generation timed out" });
stops.current.delete(id);
trackEvent("video_generation", { model: H3.modelId, status: "timeout" });
} else {
setTimeout(poll, POLL_INTERVAL_MS);
}
@@ -136,6 +141,14 @@ export default function H3PromptBox() {
setError(`Prompt exceeds the ${MAX_PROMPT_LENGTH} character limit for this model`);
return;
}
trackEvent("prompt_submit", {
model: H3.modelId,
logged_in: loggedIn,
aspect_ratio: aspectRatio,
resolution,
duration_seconds: Number(duration),
sound,
});
if (!loggedIn) {
setGateOpen(true);
return;
@@ -2,6 +2,7 @@
import { useState } from "react";
import { PlayIcon } from "lucide-react";
import { trackAttrs } from "@/lib/analytics";
// Click-to-load YouTube embed: a thumbnail until the visitor asks for the
// player, so the landing never pays the iframe cost up front.
@@ -32,6 +33,11 @@ export default function LiteYouTube({
onClick={() => setActivated(true)}
className="group absolute inset-0 h-full w-full"
aria-label={`Play video: ${title}`}
{...trackAttrs("video_start", {
video_provider: "youtube",
video_title: title,
video_url: `https://www.youtube.com/watch?v=${videoId}`,
})}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react";
import Link from "next/link";
import { ArrowDownToLineIcon, CheckIcon, CopyIcon, ExternalLinkIcon } from "lucide-react";
import { Button } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { MEDIA_LABELS, corsMediaUrl, mediaDetailLabel, mediaFormat, mediaKind, mediaThumbnail, safeMediaUrl, type MediaDimensions, type MediaPrompt, type SharedMedia } from "@/lib/media";
import { getModelDisplayName, getModelIcon, getProviderDisplayName, getProviderIcon } from "@/lib/model-names";
@@ -35,6 +36,7 @@ export default function MediaDetails({ media, prompt, promptLoading, dimensions
}
async function download() {
trackEvent("media_download", { media_type: kind, file_extension: mediaFormat(media) });
setDownloading(true);
setMessage("");
const controller = new AbortController();
@@ -102,7 +104,10 @@ export default function MediaDetails({ media, prompt, promptLoading, dimensions
<h2 className="hud-label text-faint">Prompt</h2>
{prompt?.maybe_positive_prompt && (
<button type="button" className="text-muted hover:text-ink" aria-label={copied === "prompt" ? "Prompt copied" : "Copy prompt"}
onClick={() => copy(prompt.maybe_positive_prompt!, "prompt")}>
onClick={() => {
copy(prompt.maybe_positive_prompt!, "prompt");
trackEvent("copy_prompt", { media_type: kind });
}}>
{copied === "prompt" ? <CheckIcon className="h-4 w-4" /> : <CopyIcon className="h-4 w-4" />}
</button>
)}
@@ -143,7 +148,10 @@ export default function MediaDetails({ media, prompt, promptLoading, dimensions
<Button onClick={download} loading={downloading} className="w-full">
<ArrowDownToLineIcon aria-hidden className="h-4 w-4" />{downloading ? "Downloading" : "Download file"}
</Button>
<Button variant="secondary" className="w-full" onClick={() => copy(`${SHARE_URL_BASE}${encodeURIComponent(media.token)}`, "link")}>
<Button variant="secondary" className="w-full" onClick={() => {
copy(`${SHARE_URL_BASE}${encodeURIComponent(media.token)}`, "link");
trackEvent("share", { method: "copy_link", content_type: kind, item_id: media.token });
}}>
{copied === "link" ? <CheckIcon aria-hidden className="h-4 w-4" /> : <CopyIcon aria-hidden className="h-4 w-4" />}
{copied === "link" ? "Link copied" : "Copy share link"}
</Button>
@@ -11,6 +11,7 @@ import {
import { twMerge } from "tailwind-merge";
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
import { Button, Modal } from "@/components/ui";
import { trackAttrs } from "@/lib/analytics";
import { mediaUrl } from "@/lib/links";
import {
PRESS_KIT_CATEGORIES,
@@ -148,6 +149,10 @@ function AssetCard({
onClick={onPlay}
aria-label={`Play ${asset.title}`}
className="group relative aspect-video w-full overflow-hidden bg-bg-sunken"
{...trackAttrs("video_start", {
video_provider: asset.type === "embed" ? "youtube" : "self_hosted",
video_title: asset.title,
})}
>
{thumbnail}
</button>
@@ -175,6 +180,7 @@ function AssetCard({
rel="noopener noreferrer"
variant="secondary"
className="w-full"
{...trackAttrs("press_kit_download", { asset_name: asset.title })}
>
<ArrowDownToLineIcon aria-hidden className="h-3.5 w-3.5" />
{asset.downloadLabel ?? "Download"}
@@ -3,6 +3,7 @@
import { useState } from "react";
import { CoinsIcon } from "lucide-react";
import { Button } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { webappUrl } from "@/lib/links";
import { useAccount } from "@/lib/use-account";
import CreditsModal from "./credits-modal";
@@ -28,7 +29,10 @@ export default function CreditsCta() {
<Button
type="button"
variant="secondary"
onClick={() => setOpen(true)}
onClick={() => {
setOpen(true);
trackEvent("view_credit_packs", {});
}}
className="mt-6"
>
<CoinsIcon aria-hidden className="h-3.5 w-3.5 text-accent-ink" />
@@ -4,6 +4,7 @@ import { useState } from "react";
import { CoinsIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { Button, Modal } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import { creditsPackCheckout } from "@/lib/api";
type CreditPack = {
@@ -35,6 +36,20 @@ export default function CreditsModal({
const purchase = async (pack: CreditPack) => {
setPurchasingId(pack.id);
setError(null);
trackEvent("begin_checkout", {
currency: "USD",
value: pack.priceUsd,
checkout_type: "credits",
items: [
{
item_id: pack.id,
item_name: `${pack.total.toLocaleString("en-US")} credits`,
item_category: "credits",
price: pack.priceUsd,
quantity: 1,
},
],
});
const result = await creditsPackCheckout(pack.id);
if (result.success) {
window.location.href = result.data.checkoutUrl;
@@ -11,6 +11,7 @@ import {
} from "lucide-react";
import { twMerge } from "tailwind-merge";
import { Badge, Button, TabSelector } from "@/components/ui";
import { trackEvent } from "@/lib/analytics";
import {
getPortalUrl,
subscriptionCheckout,
@@ -85,9 +86,26 @@ export default function PricingTable({
if (plan.slug === activePlanSlug) return;
setProcessingPlan(plan.slug);
setError(null);
const checkoutType = !user ? "signup" : hasActivePlan ? "switch" : "subscribe";
const price = cadence === "yearly" ? plan.yearlyPrice : plan.monthlyPrice;
trackEvent("begin_checkout", {
currency: "USD",
value: price,
checkout_type: checkoutType,
items: [
{
item_id: plan.slug,
item_name: plan.name,
item_category: "subscription",
item_variant: cadence,
price,
quantity: 1,
},
],
});
const body = { plan: plan.slug, cadence };
const redirected = redirect(
!user
checkoutType === "signup"
? await userSignupSubscriptionCheckout({
...body,
maybeReferralUrl: getReferrer(),
@@ -95,7 +113,7 @@ export default function PricingTable({
maybeReferralUsername: getReferralUsername(),
maybeReferralCode: getReferralCode(),
})
: hasActivePlan
: checkoutType === "switch"
? await switchPlan(body)
: await subscriptionCheckout(body),
);
@@ -103,6 +121,7 @@ export default function PricingTable({
};
const managePlan = async () => {
trackEvent("manage_subscription", {});
setManaging(true);
setError(null);
if (!redirect(await getPortalUrl())) setManaging(false);
@@ -115,7 +134,11 @@ export default function PricingTable({
<TabSelector
tabs={BILLING_TABS}
activeTab={cadence}
onTabChange={(id) => setCadence(id as BillingCadence)}
onTabChange={(id) => {
if (id === cadence) return;
setCadence(id as BillingCadence);
trackEvent("billing_cadence_change", { cadence: id });
}}
tabClassName="w-24"
/>
<Badge
@@ -363,6 +386,7 @@ function ContactButtons({ className }: { className?: string }) {
try {
await navigator.clipboard.writeText(CONTACT_EMAIL);
setCopied(true);
trackEvent("contact_click", { method: "copy_email" });
setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard unavailable (insecure context) — the mailto still works.
@@ -86,6 +86,7 @@ export function Button({
</>
);
// Link buttons carry data-cta so analytics can report them as CTA clicks.
if (href) {
const anchorProps =
rest as unknown as AnchorHTMLAttributes<HTMLAnchorElement>;
@@ -96,6 +97,7 @@ export function Button({
target={target}
rel={rel}
className={classes}
data-cta=""
{...anchorProps}
>
{content}
@@ -103,7 +105,7 @@ export function Button({
);
}
return (
<Link href={href} className={classes} {...anchorProps}>
<Link href={href} className={classes} data-cta="" {...anchorProps}>
{content}
</Link>
);
@@ -0,0 +1,232 @@
// Google Analytics 4 (gtag.js). Off unless NEXT_PUBLIC_GA_MEASUREMENT_ID is
// set at build time, so local dev stays out of the reports.
//
// Page views come from GA4 Enhanced Measurement (it records App Router
// navigations via history changes), along with scrolls, outbound clicks and
// common file downloads. On top of that, the site sends the events below.
// Most link clicks need no per-component code: one delegated listener
// (see components/analytics.tsx) classifies them by href, and elements
// tagged with trackAttrs() report their event when clicked. Every delegated
// event also carries `link_location` (nav, footer, modal or the enclosing
// section id).
import { SOCIAL_LINKS, WEBAPP_URL } from "./links";
export const GA_MEASUREMENT_ID =
process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID?.trim() || undefined;
export type AnalyticsItem = {
item_id: string;
item_name: string;
item_category: "subscription" | "credits";
item_variant?: string;
price: number;
quantity: number;
};
type AnalyticsEvents = {
// Link clicks, classified by href.
app_download: {
app_name: string;
app_version: string;
platform: "macos" | "windows" | "linux" | "other";
file_name: string;
};
webapp_click: { destination: string; link_text: string };
social_click: { network: string };
contact_click: { method: "mailto" | "copy_email" };
cta_click: { link_url: string; link_text: string };
// Clicks on elements tagged with trackAttrs().
video_start: { video_provider: string; video_title: string; video_url?: string };
share: { method: string; content_type: string; item_id: string };
copy_command: { app_name: string };
app_select: { app_name: string };
press_kit_download: { asset_name: string };
// Sent from component code.
generate_lead: { form_name: string; user_type: string };
sign_up: { method: "email"; signup_source: string };
login: { method: "email" };
prompt_submit: {
model: string;
logged_in: boolean;
aspect_ratio: string;
resolution: string;
duration_seconds: number;
sound: boolean;
};
video_generation: { model: string; status: "complete" | "failed" | "timeout" };
billing_cadence_change: { cadence: string };
view_credit_packs: Record<string, never>;
begin_checkout: {
currency: "USD";
value: number;
checkout_type: "signup" | "subscribe" | "switch" | "credits";
items: AnalyticsItem[];
};
manage_subscription: Record<string, never>;
copy_prompt: { media_type: string };
media_download: { media_type: string; file_extension: string };
};
export type AnalyticsEvent = keyof AnalyticsEvents;
type ParamValue = string | number | boolean | AnalyticsItem[] | undefined;
type EventParams = Record<string, ParamValue>;
declare global {
interface Window {
gtag?: (...args: unknown[]) => void;
}
}
export function trackEvent<E extends AnalyticsEvent>(
event: E,
params: AnalyticsEvents[E],
): void {
send(event, params);
}
/**
* Data attributes that make an element report `event` when clicked. Works in
* server components (no handler needed); spread onto the element or Button.
*/
export function trackAttrs<E extends AnalyticsEvent>(
event: E,
params: AnalyticsEvents[E],
): Record<`data-track${string}`, string> {
const attrs: Record<`data-track${string}`, string> = { "data-track": event };
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) attrs[`data-track-${key}`] = String(value);
}
return attrs;
}
/** Delegated click handler: reports tagged elements and classified links. */
export function trackClick(target: EventTarget | null): void {
if (!(target instanceof Element)) return;
const tagged = target.closest<HTMLElement>("[data-track]");
if (tagged?.dataset.track) {
send(tagged.dataset.track, {
...taggedParams(tagged),
link_location: linkLocation(tagged),
});
return;
}
const anchor = target.closest<HTMLAnchorElement>("a[href]");
const link = anchor && classifyLink(anchor);
if (link) send(link.event, { ...link.params, link_location: linkLocation(anchor) });
}
function send(event: string, params: EventParams): void {
if (!GA_MEASUREMENT_ID) return;
window.gtag?.("event", event, params);
}
const WEBAPP_ORIGIN = new URL(WEBAPP_URL).origin;
// Hostname → network, derived from SOCIAL_LINKS.
const SOCIAL_NETWORKS = new Map(
Object.entries(SOCIAL_LINKS).map(([name, href]) => [
bareHostname(new URL(href).hostname),
name.toLowerCase(),
]),
);
// github.com/<org>/<repo>/releases/download/<tag>/<file>: the ArtCraft
// installers and every Crafting App download.
const RELEASE_ASSET_PATH = /^\/[^/]+\/([^/]+)\/releases\/download\/([^/]+)\/([^/]+)$/;
type ClassifiedLink = {
[E in AnalyticsEvent]: { event: E; params: AnalyticsEvents[E] };
}[AnalyticsEvent];
function classifyLink(anchor: HTMLAnchorElement): ClassifiedLink | null {
let url: URL;
try {
url = new URL(anchor.href);
} catch {
return null;
}
if (url.protocol === "mailto:") {
return { event: "contact_click", params: { method: "mailto" } };
}
const release = url.hostname === "github.com" && RELEASE_ASSET_PATH.exec(url.pathname);
if (release) {
const [, repo, tag, file] = release;
const fileName = decodeURIComponent(file);
return {
event: "app_download",
params: {
app_name: repo,
app_version: tag.replace(/^\D*/, ""),
platform: downloadPlatform(fileName),
file_name: fileName,
},
};
}
if (url.origin === WEBAPP_ORIGIN) {
return {
event: "webapp_click",
params: { destination: url.pathname, link_text: linkText(anchor) },
};
}
const network = SOCIAL_NETWORKS.get(bareHostname(url.hostname));
if (network) return { event: "social_click", params: { network } };
// Remaining Button links (internal CTAs, other outbound buttons). Plain
// text links are left to page views and Enhanced Measurement.
if (anchor.hasAttribute("data-cta")) {
return {
event: "cta_click",
params: {
link_url: url.origin === window.location.origin ? url.pathname + url.hash : url.href,
link_text: linkText(anchor),
},
};
}
return null;
}
function linkLocation(element: Element): string {
if (element.closest("#site-nav")) return "nav";
if (element.closest("footer")) return "footer";
if (element.closest('[role="dialog"]')) return "modal";
return element.closest("section[id]")?.id || "page";
}
// data-track-<param> attributes surface in the dataset as track<Param>.
function taggedParams(element: HTMLElement): EventParams {
const params: EventParams = {};
for (const [key, value] of Object.entries(element.dataset)) {
if (key.length > 5 && key.startsWith("track")) {
params[key.charAt(5).toLowerCase() + key.slice(6)] = value;
}
}
return params;
}
function downloadPlatform(fileName: string): AnalyticsEvents["app_download"]["platform"] {
if (/\.dmg$|macos/i.test(fileName)) return "macos";
if (/\.(exe|msi)$|windows/i.test(fileName)) return "windows";
if (/\.(appimage|deb|rpm)$|linux/i.test(fileName)) return "linux";
return "other";
}
// GA truncates parameter values at 100 characters.
function linkText(anchor: HTMLAnchorElement): string {
const text = anchor.getAttribute("aria-label") || anchor.textContent || "";
return text.replace(/\s+/g, " ").trim().slice(0, 100);
}
function bareHostname(hostname: string): string {
return hostname.replace(/^www\./, "");
}
@@ -12,7 +12,7 @@ import type { CraftAppSlug } from "./crafting-apps";
//
// Release tags are `v<version>` and live at github.com/storytold/<slug>.
export const CRAFTING_APPS_LATEST_VERSION = "0.2.0";
export const CRAFTING_APPS_LATEST_VERSION = "0.2.1";
export type CraftDesktopPlatform = "macOS" | "Windows" | "Linux";
@@ -43,6 +43,8 @@ export type CraftRelease = {
version: string;
/** Git tag. Defaults to `v<version>`. */
tag?: string;
/** Fills `{slug}` in filenames. Defaults to the app's slug; a release from before a rename keeps the old one. */
fileSlug?: string;
assets: readonly CraftReleaseAsset[];
};
@@ -171,15 +173,35 @@ export function craftRelease(
return { version, assets };
}
// Windows on ARM builds, which only some apps' releases include so far.
const WITH_WINDOWS_ARM64: readonly CraftReleaseAsset[] = [
...CRAFT_RELEASE_ASSETS,
{
file: "{slug}-{version}-windows-arm64.msi",
group: "Windows",
label: "Installer (ARM64)",
description: "Windows 11 on ARM laptops.",
arch: "aarch64",
},
{
file: "{slug}-{version}-windows-arm64-portable.zip",
group: "Windows",
label: "Portable (ARM64)",
description: "Unzip and run on Windows on ARM.",
arch: "aarch64",
},
];
const LATEST = craftRelease(CRAFTING_APPS_LATEST_VERSION);
// Every app must be listed, so a new slug fails the type check until it is.
export const CRAFT_APP_RELEASES: Record<CraftAppSlug, CraftRelease | null> = {
photocraft: LATEST,
vectorcraft: LATEST,
photocraft: craftRelease("0.3.0", WITH_WINDOWS_ARM64),
vectorcraft: craftRelease("0.5.0", WITH_WINDOWS_ARM64),
filmcraft: LATEST,
lightcraft: LATEST,
printcraft: LATEST,
effectcraft: LATEST,
// 0.2.1 shipped before the PrintCraft → PdfCraft rename.
pdfcraft: { ...LATEST, fileSlug: "printcraft" },
effectcraft: craftRelease("0.4.0", WITH_WINDOWS_ARM64),
designcraft: LATEST,
};
@@ -25,7 +25,7 @@ export type CraftAppSlug =
| "vectorcraft"
| "filmcraft"
| "lightcraft"
| "printcraft"
| "pdfcraft"
| "effectcraft"
| "designcraft";
@@ -177,25 +177,31 @@ export const CRAFTING_APPS: CraftApp[] = [
file: "hero.webp",
source: "shot-1-neon.png",
caption: "Neon Drive — made in VectorCraft",
alt: "VectorCraft editing a synthwave poster titled Neon Drive with an outer glow in the Appearance panel",
alt: "VectorCraft editing the Neon Drive poster, with the title's live outer glow settings in the Appearance panel and its character settings in the Properties panel",
},
{
file: "ribbons.webp",
source: "shot-2-ribbons.png",
caption: "Live blends between editable spines",
alt: "VectorCraft showing live blends: 70 smooth-color steps between two ribbon paths",
caption: "Live blends and layers",
alt: "VectorCraft showing three live blend ribbons clipped to the artboard, one selected with its key paths, and the Layers panel listing the clip group and blends",
},
{
file: "bezier.webp",
source: "shot-4-bezier.png",
caption: "Pen and direct selection",
alt: "VectorCraft direct-selecting the anchor points and bezier handles of a crescent moon, with the contextual task bar below it",
},
{
file: "perspective.webp",
source: "shot-5-perspective.png",
caption: "Perspective grid, still editable",
alt: "VectorCraft with two lit building facades drawn on the left and right planes of a two-point perspective grid at sunset",
},
{
file: "sheet.webp",
source: "shot-3-sheet.png",
caption: "Booleans, mesh, repeat and envelope",
alt: "VectorCraft feature sheet with exact booleans, a gradient mesh, a live radial repeat and an envelope distort",
},
{
file: "bezier.webp",
source: "shot-4-bezier.png",
caption: "Direct selection on bezier anchors",
alt: "VectorCraft direct-selecting the anchors and handles of a crescent moon path",
alt: "VectorCraft in its light theme with four artboards: shape booleans, a gradient mesh, a live radial repeat and an envelope distort",
},
],
},
@@ -328,8 +334,8 @@ export const CRAFTING_APPS: CraftApp[] = [
],
},
{
slug: "printcraft",
prefix: "Print",
slug: "pdfcraft",
prefix: "Pdf",
category: "PDF workbench",
status: "Early alpha",
headline: ["The ", "open-source", " PDF workbench."],
@@ -367,27 +373,27 @@ export const CRAFTING_APPS: CraftApp[] = [
shots: [
{
file: "hero.webp",
source: "printcraft-viewer.png",
source: "pdfcraft-viewer.png",
caption: "Viewer with threaded comments",
alt: "PrintCraft showing a showcase PDF cover with the tools sidebar and threaded comments",
alt: "PdfCraft showing a showcase PDF cover with the All tools panel and threaded comments",
},
{
file: "organize.webp",
source: "printcraft-organize.png",
source: "pdfcraft-organize.png",
caption: "Organize pages like cards",
alt: "PrintCraft page organizer with three pages selected for rotate, delete and extract",
alt: "PdfCraft page organizer with three pages selected and the page tools in the toolbar above",
},
{
file: "palette.webp",
source: "printcraft-palette.png",
source: "pdfcraft-palette.png",
caption: "A command palette for every tool",
alt: "PrintCraft command palette filtering page tools by the word page",
alt: "PdfCraft command palette searching for page and listing the page tools",
},
{
file: "twoup.webp",
source: "printcraft-twoup.png",
source: "pdfcraft-twoup.png",
caption: "Two-up Read mode, dark theme",
alt: "PrintCraft two-up Read mode in the dark theme showing typeset specimen pages",
alt: "PdfCraft two-up Read mode in the dark theme showing typeset specimen pages",
},
],
},
@@ -649,7 +655,7 @@ export function craftReleaseDownloads(
const base = `${craftAppRepo(app)}/releases/download/${craftReleaseTag(release)}`;
return release.assets.map(({ file, ...asset }) => {
const fileName = file
.replaceAll("{slug}", app.slug)
.replaceAll("{slug}", release.fileSlug ?? app.slug)
.replaceAll("{version}", release.version);
return { ...asset, fileName, href: `${base}/${fileName}` };
});
@@ -3,6 +3,7 @@ import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import Konva from "konva";
import { disposeObject3D } from "@storyteller/ui-viewer-3d";
import { Node } from "../Node";
import {
Model3DParams,
@@ -226,11 +227,17 @@ export const Model3DOverlay = React.forwardRef<Model3DOverlayHandle, Model3DOver
front.position.set(0, 4, 10);
scene.add(front);
// Load model
// Load model. Set by cleanup: a model that finishes loading after the
// overlay closed or switched models is freed instead of added.
let cancelled = false;
const loader = new GLTFLoader();
loader.load(
node.modelUrl!,
(gltf) => {
if (cancelled) {
disposeObject3D(gltf.scene);
return;
}
const model = gltf.scene;
// Auto-fit + apply user scale
@@ -261,18 +268,15 @@ export const Model3DOverlay = React.forwardRef<Model3DOverlayHandle, Model3DOver
animate();
return () => {
cancelled = true;
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
controls.dispose();
scene.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry?.dispose();
const mats = Array.isArray(obj.material)
? obj.material
: [obj.material];
mats.forEach((m) => m?.dispose());
}
});
disposeObject3D(scene);
renderer.dispose();
// A model change re-runs this effect on the same canvas, which hands
// the next renderer this same context, so only force the loss once
// the canvas has left the DOM (the overlay closed).
if (!canvas.isConnected) renderer.forceContextLoss();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [node.modelUrl]);
@@ -0,0 +1,82 @@
import * as THREE from "three";
import {
DEFAULT_MODEL3D_PARAMS,
render3DModelToDataUrl,
} from "./render3DModel";
const { renderers, gltfOutcome } = vi.hoisted(() => ({
renderers: [] as FakeRenderer[],
gltfOutcome: { scene: undefined as unknown, error: undefined as unknown },
}));
type FakeRenderer = {
dispose: ReturnType<typeof vi.fn>;
forceContextLoss: ReturnType<typeof vi.fn>;
};
// jsdom has no WebGL; only the teardown calls matter here.
vi.mock("three", async (importOriginal) => {
const actual = await importOriginal<typeof import("three")>();
class WebGLRenderer {
domElement = { toDataURL: () => "data:image/png;base64,AAAA" };
dispose = vi.fn();
forceContextLoss = vi.fn();
constructor() {
renderers.push(this);
}
setClearColor() {}
setSize() {}
render() {}
}
return { ...actual, WebGLRenderer };
});
vi.mock("three/examples/jsm/loaders/GLTFLoader.js", () => ({
GLTFLoader: class {
load(
_url: string,
onLoad: (gltf: { scene: unknown }) => void,
_onProgress: unknown,
onError: (error: unknown) => void,
) {
if (gltfOutcome.error) onError(gltfOutcome.error);
else onLoad({ scene: gltfOutcome.scene });
}
},
}));
describe("render3DModelToDataUrl", () => {
beforeEach(() => {
renderers.length = 0;
gltfOutcome.scene = undefined;
gltfOutcome.error = undefined;
});
it("frees the model and the renderer's context after rendering", async () => {
const geometry = new THREE.BoxGeometry();
const onDispose = vi.fn();
geometry.addEventListener("dispose", onDispose);
gltfOutcome.scene = new THREE.Mesh(geometry);
const dataUrl = await render3DModelToDataUrl(
"model.glb",
DEFAULT_MODEL3D_PARAMS,
);
expect(dataUrl).toMatch(/^data:image\/png/);
expect(onDispose).toHaveBeenCalledTimes(1);
expect(renderers[0].dispose).toHaveBeenCalledTimes(1);
expect(renderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
});
it("frees the renderer's context when the model fails to load", async () => {
gltfOutcome.error = new Error("404");
await expect(
render3DModelToDataUrl("missing.glb", DEFAULT_MODEL3D_PARAMS),
).rejects.toThrow("404");
expect(renderers[0].dispose).toHaveBeenCalledTimes(1);
expect(renderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
});
});
@@ -1,5 +1,6 @@
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { disposeObject3D } from "@storyteller/ui-viewer-3d";
export interface Model3DParams {
cameraPosition: { x: number; y: number; z: number };
@@ -76,18 +77,16 @@ function normalizeAndPlaceModel(
model.position.y = -scaledBox.min.y;
}
// The renderer and its canvas are throwaway, so free the scene and then the
// context itself; otherwise each render holds a WebGL context until the
// canvas is garbage collected.
function disposeScene(
scene: THREE.Scene,
renderer: THREE.WebGLRenderer,
): void {
scene.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry?.dispose();
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach((m) => m?.dispose());
}
});
disposeObject3D(scene);
renderer.dispose();
renderer.forceContextLoss();
}
export async function render3DModelToDataUrl(
@@ -88,6 +88,9 @@
},
{
"path": "../modal/tsconfig.lib.json"
},
{
"path": "../viewer-3d/tsconfig.lib.json"
}
]
}
@@ -1127,11 +1127,27 @@ class Editor {
this.bus.emit(new SceneLoadedEvent(false));
this.stopRenderLoop();
this.viewport.dispose();
// Free the scene's GPU resources. EngineProvider serialized the scene
// before calling us, and the next mount builds a fresh Editor, so
// nothing reads this scene again. The gizmo's dispose also removes its
// pointer listeners from the canvas.
this.entranceAnimator.clear();
this.gizmo.control?.dispose();
this.activeScene.dispose();
// Fix: dispose 3D contexts
this.renderer?.dispose();
this.postProcessing.dispose();
this.rawRenderer?.dispose();
// dispose() leaves the GL objects themselves (render targets, programs)
// in the context. Leaving the page detaches the canvases, and the
// context then lingers until garbage collection, two per visit. A
// scene change keeps the canvases mounted and the next Editor reuses
// their contexts, so only release a context whose canvas is gone.
releaseContextIfDetached(this.renderer);
releaseContextIfDetached(this.rawRenderer);
this.isMounted = false;
this.bus.emit(new EngineInitializedEvent(false));
@@ -1145,4 +1161,10 @@ class Editor {
}
}
function releaseContextIfDetached(renderer: THREE.WebGLRenderer | undefined) {
if (renderer && !renderer.domElement.isConnected) {
renderer.forceContextLoss();
}
}
export default Editor;
@@ -0,0 +1,38 @@
import { ViewportController } from "./ViewportController";
describe("ViewportController", () => {
it("stops observing the container on dispose", () => {
const observers = stubResizeObserver();
const renderScene = vi.fn();
const viewport = new ViewportController({
getCamera: () => null,
getRenderCamera: () => null,
getRenderer: () => ({ setSize: vi.fn(), setPixelRatio: vi.fn() }) as never,
getRenderAspectRatio: () => 1,
resizePostProcessing: vi.fn(),
renderScene,
});
viewport.container = document.createElement("div");
viewport.setupResizeObserver();
viewport.dispose();
expect(observers[0].disconnect).toHaveBeenCalledTimes(1);
});
});
// jsdom has no ResizeObserver; record each one the controller creates.
function stubResizeObserver() {
const observers: Array<{ disconnect: ReturnType<typeof vi.fn> }> = [];
vi.stubGlobal(
"ResizeObserver",
class {
disconnect = vi.fn();
constructor() {
observers.push(this);
}
observe() {}
},
);
return observers;
}
@@ -100,4 +100,12 @@ export class ViewportController {
});
this.observer.observe(this.container);
}
// Stop observing on unmount. The container can outlive this editor (a
// scene change keeps it mounted), and a live observer would keep
// re-rendering the dead editor's scene on every resize.
dispose() {
this.observer?.disconnect();
this.observer = undefined;
}
}
@@ -148,45 +148,11 @@ function removeObject3D(object3D) {
return;
}
// Finally remove the object from the scene
// Finally remove the object from the scene and free its GPU resources.
// Undo re-loads deleted objects from a snapshot (DeleteAction), so
// nothing reuses this instance.
this.scene.scene.remove(obj);
obj.traverse(child => {
(child as THREE.Mesh)?.geometry?.dispose()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const childTexture = (child as any).texture;
if (Array.isArray(childTexture)) {
childTexture.forEach(mat => mat.dispose());
} else if (childTexture) {
childTexture.dispose();
}
const childMaterial = (child as THREE.Mesh).material;
if (Array.isArray(childMaterial)) {
childMaterial.forEach(mat => mat.dispose());
} else if (childMaterial) {
childMaterial.dispose();
}
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const objTexture = (obj as any).texture;
if (Array.isArray(objTexture)) {
objTexture.forEach(mat => mat.dispose());
} else if (objTexture) {
objTexture.dispose();
}
const objMaterial = (obj as THREE.Mesh).material;
if (Array.isArray(objMaterial)) {
objMaterial.forEach(mat => mat.dispose());
} else if (objMaterial) {
objMaterial.dispose();
}
if ((obj as THREE.Mesh).geometry) {
(obj as THREE.Mesh).geometry.dispose()
}
this.scene.disposeObject(obj);
this.deps.bus.emit(new ObjectRemovedEvent(uuid));
this.deps.clearSelected();
@@ -0,0 +1,167 @@
import * as THREE from "three";
import Scene from "./scene";
// Scene imports Spark for splat loading; none of these tests load one.
vi.mock("@sparkjsdev/spark", async () => {
const { Object3D } = await import("three");
return {
SplatMesh: class extends Object3D {},
SparkRenderer: class extends Object3D {},
};
});
// The Addons barrel pulls in a lottie canvas module jsdom cannot run; Scene
// only needs these two names from it.
vi.mock("three/examples/jsm/Addons.js", async () => {
const { Mesh } = await import("three");
return { MMDAnimationHelper: class {}, Water: class extends Mesh {} };
});
describe("Scene disposal", () => {
describe("disposeObject", () => {
it("frees the object's GPU resources", () => {
const scene = makeScene();
const geometry = new THREE.BoxGeometry();
const onDispose = vi.fn();
geometry.addEventListener("dispose", onDispose);
scene.disposeObject(new THREE.Mesh(geometry));
expect(onDispose).toHaveBeenCalledTimes(1);
});
it("stops shader ticks for disposed water but keeps the rest", () => {
const scene = makeScene();
const water = waterLikeMesh();
const otherWater = waterLikeMesh();
scene.shader_objects = [water, otherWater] as never;
const group = new THREE.Group();
group.add(water);
scene.disposeObject(group);
expect(scene.shader_objects).toEqual([otherWater]);
});
it("stops and forgets the video behind a video plane", () => {
const scene = makeScene();
const video = stubbedVideo();
const otherVideo = stubbedVideo();
scene.video_planes = [video, otherVideo];
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(),
new THREE.MeshBasicMaterial({ map: new THREE.VideoTexture(video) }),
);
scene.disposeObject(plane);
expect(video.pause).toHaveBeenCalled();
expect(video.load).toHaveBeenCalled();
expect(video.hasAttribute("src")).toBe(false);
expect(scene.video_planes).toEqual([otherVideo]);
expect(otherVideo.pause).not.toHaveBeenCalled();
});
it("removes MMD meshes from the animation helper", () => {
const scene = makeScene();
const mmd = new THREE.SkinnedMesh();
const other = new THREE.SkinnedMesh();
const remove = vi.fn();
scene.helper = { meshes: [mmd, other], remove } as never;
scene.disposeObject(mmd);
expect(remove).toHaveBeenCalledTimes(1);
expect(remove).toHaveBeenCalledWith(mmd);
});
});
describe("disposeContents", () => {
it("empties the scene and frees every child", () => {
const scene = makeScene();
const geometries = [new THREE.BoxGeometry(), new THREE.SphereGeometry()];
const disposed = new Set<THREE.BufferGeometry>();
for (const geometry of geometries) {
geometry.addEventListener("dispose", () => disposed.add(geometry));
scene.scene.add(new THREE.Mesh(geometry));
}
const child = scene.scene.children[0];
scene.hot_items = [child];
scene.disposeContents();
expect(scene.scene.children).toHaveLength(0);
expect(child.parent).toBeNull();
expect(scene.hot_items).toEqual([]);
expect(disposed).toEqual(new Set(geometries));
});
it("leaves the background for the skybox to replace", () => {
const scene = makeScene();
const background = new THREE.CubeTexture();
const onDispose = vi.fn();
background.addEventListener("dispose", onDispose);
scene.scene.background = background;
scene.disposeContents();
expect(scene.scene.background).toBe(background);
expect(onDispose).not.toHaveBeenCalled();
});
});
it("dispose frees the contents and the background", () => {
const scene = makeScene();
const geometry = new THREE.BoxGeometry();
const background = new THREE.CubeTexture();
const disposed = new Set<unknown>();
geometry.addEventListener("dispose", () => disposed.add(geometry));
background.addEventListener("dispose", () => disposed.add(background));
scene.scene.add(new THREE.Mesh(geometry));
scene.scene.background = background;
scene.dispose();
expect(disposed).toEqual(new Set([geometry, background]));
expect(scene.scene.children).toHaveLength(0);
expect(scene.scene.background).toBeNull();
});
it("disposeBackground frees a texture background", () => {
const scene = makeScene();
const background = new THREE.CubeTexture();
const onDispose = vi.fn();
background.addEventListener("dispose", onDispose);
scene.scene.background = background;
scene.disposeBackground();
expect(onDispose).toHaveBeenCalledTimes(1);
});
});
function makeScene(): Scene {
return new Scene("test", "::CAM::", () => undefined, 1, {
getCameras: () => [],
getSelectedCameraId: () => "main",
fetchAsset: () => Promise.reject(new Error("no network in tests")),
getMediaUrlByToken: () => Promise.reject(new Error("no network in tests")),
});
}
// Scene only tracks Water instances by identity; a plain mesh stands in.
function waterLikeMesh(): THREE.Mesh {
return new THREE.Mesh(
new THREE.PlaneGeometry(),
new THREE.MeshBasicMaterial(),
);
}
// jsdom does not implement media playback, so stub the calls stopVideo makes.
function stubbedVideo(): HTMLVideoElement {
const video = document.createElement("video");
video.src = "https://example.invalid/clip.mp4";
video.pause = vi.fn();
video.load = vi.fn();
return video;
}
@@ -13,6 +13,10 @@ import type { Camera } from "@storyteller/common";
import toast from "react-hot-toast";
import { SplatMesh } from "@sparkjsdev/spark";
import { ensureInternalBbox } from "./internalBbox";
import {
collectObjectTextures,
disposeObject3D,
} from "@storyteller/ui-viewer-3d";
// Capabilities Scene needs from outside its own state. Editor wires
// these in inline at construction (Phase 2 idiom — same shape as
@@ -134,16 +138,64 @@ class Scene {
}
clear() {
this.scene.children = [];
// hot_items held detached keyframe-point refs across reloads; reset it
// so it never carries stale objects from a previous scene.
this.hot_items = [];
this.disposeContents();
this._createGrid();
this._create_base_lighting();
this._create_skybox();
this._create_camera_obj();
}
// Remove everything from the scene and free its GPU resources. The
// background is left in place: _create_skybox frees it when it is
// replaced, and teardown frees it through disposeBackground.
disposeContents() {
this.disposeObject(this.scene);
this.scene.clear();
// hot_items held detached keyframe-point refs across reloads; reset it
// so it never carries stale objects from a previous scene.
this.hot_items = [];
}
// Free an object tree that has left the scene for good: its GPU
// resources, plus its entries in the per-object registries (Water
// shaders ticked every frame, video elements, MMD animation).
disposeObject(root: THREE.Object3D) {
const inTree = new Set<THREE.Object3D>();
root.traverse((child) => inTree.add(child));
this.shader_objects = this.shader_objects.filter(
(water) => !inTree.has(water),
);
for (const mesh of [...(this.helper?.meshes ?? [])]) {
if (inTree.has(mesh)) this.helper.remove(mesh);
}
const videos = new Set<HTMLVideoElement>();
for (const texture of collectObjectTextures(root)) {
if (texture instanceof THREE.VideoTexture) videos.add(texture.image);
}
for (const video of videos) stopVideo(video);
this.video_planes = this.video_planes.filter(
(video) => !videos.has(video),
);
disposeObject3D(root);
}
// Final teardown when the owning Editor unmounts: free the contents and
// the background. The Scene is not used again — every mount builds a new
// Editor, and EngineProvider serializes the scene before unmounting.
dispose() {
this.disposeContents();
this.disposeBackground();
this.scene.background = null;
}
disposeBackground() {
if (this.scene.background instanceof THREE.Texture) {
this.scene.background.dispose();
}
}
async instantiate(
name: string,
pos: THREE.Vector3 = new THREE.Vector3(0, 0, 0),
@@ -1118,6 +1170,9 @@ class Scene {
// default skybox.
_create_skybox() {
const loader = new THREE.CubeTextureLoader();
// Every branch below replaces the background, so free the cube texture
// being replaced (skybox switches and New Scene used to leak it).
this.disposeBackground();
// Theme-aware: in light mode, use a near-white background for better contrast with UI
try {
@@ -1272,4 +1327,12 @@ class Scene {
}
// Pausing alone keeps the decoder and buffered media alive; dropping the
// source and reloading is what releases them.
function stopVideo(video: HTMLVideoElement) {
video.pause();
video.removeAttribute("src");
video.load();
}
export default Scene;
@@ -0,0 +1,91 @@
import * as THREE from "three";
import Scene from "../engine/scene";
import type { LoadTicket } from "../engine/save_manager";
import { StoryTellerProxyScene } from "./storyteller_proxy_scene";
vi.mock("@sparkjsdev/spark", async () => {
const { Object3D } = await import("three");
return {
SplatMesh: class extends Object3D {},
SparkRenderer: class extends Object3D {},
};
});
// The Addons barrel pulls in a lottie canvas module jsdom cannot run.
vi.mock("three/examples/jsm/Addons.js", async () => {
const { Mesh } = await import("three");
return { MMDAnimationHelper: class {}, Water: class extends Mesh {} };
});
const BOX_JSON = {
media_file_token: "Parim",
object_name: "Box",
object_user_data_name: "Box",
object_uuid: "box-uuid",
position: new THREE.Vector3(),
rotation: new THREE.Vector3(),
scale: new THREE.Vector3(1, 1, 1),
color: "#FFFFFF",
metalness: 0,
shininess: 0.5,
specular: 0,
locked: false,
visible: true,
media_file_type: "none",
user_data: { shapeKey: "Box" },
};
describe("loadFromSceneJson", () => {
it("removes and frees objects that finish after the load is cancelled", async () => {
const scene = makeScene();
const proxy = new StoryTellerProxyScene(2, scene);
const ticket = makeTicket();
const onDispose = vi.fn();
const addListener = trackFirstMeshGeometry(scene, onDispose);
const load = proxy.loadFromSceneJson([BOX_JSON] as never, "m_1", 2, ticket);
// The box is created before the loader's first await on its tasks;
// cancel while that await is pending, as a newer load or an unmount
// would.
addListener();
ticket.cancelled = true;
await load;
expect(scene.scene.getObjectByProperty("name", "Box")).toBeUndefined();
expect(onDispose).toHaveBeenCalledTimes(1);
});
it("keeps the loaded objects when the load is not cancelled", async () => {
const scene = makeScene();
const proxy = new StoryTellerProxyScene(2, scene);
await proxy.loadFromSceneJson([BOX_JSON] as never, "m_1", 2, makeTicket());
expect(scene.get_object_by_uuid("box-uuid")).toBeDefined();
});
});
function makeScene(): Scene {
return new Scene("test", "::CAM::", () => undefined, 2, {
getCameras: () => [],
getSelectedCameraId: () => "main",
fetchAsset: () => Promise.reject(new Error("no network in tests")),
getMediaUrlByToken: () => Promise.reject(new Error("no network in tests")),
});
}
function makeTicket(): LoadTicket {
return { cancelled: false, signal: new AbortController().signal };
}
// Returns a function that, once the load has created its box, subscribes
// `onDispose` to that box's geometry.
function trackFirstMeshGeometry(
scene: Scene,
onDispose: () => void,
): () => void {
return () => {
const box = scene.scene.getObjectByProperty("name", "Box") as THREE.Mesh;
box.geometry.addEventListener("dispose", onDispose);
};
}
@@ -128,9 +128,9 @@ export class StoryTellerProxyScene {
) {
if (scene_json == null || this.scene == null) return;
while (this.scene.scene.children.length > 0) {
this.scene.scene.remove(this.scene.scene.children[0]);
}
// Free the outgoing scene before loading over it; removing the
// children alone left all of their GPU resources allocated.
this.scene.disposeContents();
// Warm Scene's URL cache up front so per-asset loadObject() calls
// never hit the network for token→URL resolution. One batch call
@@ -229,7 +229,10 @@ export class StoryTellerProxyScene {
);
const settled = await Promise.allSettled(tasks);
if (ticket?.cancelled) return;
if (ticket?.cancelled) {
this.discardLoadedObjects(settled);
return;
}
// Synchronous transform-application pass. We walk results in
// original JSON order to preserve scene.children insertion
@@ -295,4 +298,21 @@ export class StoryTellerProxyScene {
this.scene._createGrid();
this.scene.updateSkybox(skybox_media_id);
}
// A cancelled load has still added whatever finished loading to the
// scene. Take those objects back out and free them, or they would leak
// and, when a newer load superseded this one, show up in its scene.
// Lights are skipped: Scene tracks its base lights in fields that a
// newer load may have re-pointed at them.
private discardLoadedObjects(
settled: PromiseSettledResult<{ obj: THREE.Object3D | undefined }>[],
) {
for (const result of settled) {
if (result.status !== "fulfilled") continue;
const { obj } = result.value;
if (!obj || obj instanceof THREE.Light) continue;
obj.removeFromParent();
this.scene.disposeObject(obj);
}
}
}
@@ -124,6 +124,9 @@
},
{
"path": "../../icons/tsconfig.lib.json"
},
{
"path": "../viewer-3d/tsconfig.lib.json"
}
]
}
@@ -160,12 +160,13 @@ export const UploadFiles3D = ({
});
};
// Tears down the current preview: stops its loop, cancels in-flight
// loads and frees its scene and renderer (see loadPreviewOnCanvas).
const disposePreviewRef = useRef<(() => void) | null>(null);
const disposeRenderer = () => {
if (rendererRef.current) {
rendererRef.current.setAnimationLoop(null);
rendererRef.current.dispose();
rendererRef.current = null;
}
disposePreviewRef.current?.();
disposePreviewRef.current = null;
rendererRef.current = null;
};
// Normalize an FBX to GLB in the background. Completion swaps the entry's
@@ -237,7 +238,7 @@ export const UploadFiles3D = ({
return;
}
const { renderer, camera, selectAnimation, setSkeletonVisible, cancel } =
const { renderer, camera, selectAnimation, setSkeletonVisible, dispose } =
loadPreviewOnCanvas({
file: currentFile,
canvas: canvasRef.current,
@@ -250,17 +251,15 @@ export const UploadFiles3D = ({
},
});
rendererRef.current = renderer;
disposePreviewRef.current = dispose;
cameraRef.current = camera;
selectAnimationRef.current = selectAnimation;
setSkeletonVisibleRef.current = setSkeletonVisible;
return () => {
// Cancel BEFORE disposing: an in-flight load's callbacks must not
// render on the disposed renderer or write into the next preview's
// status/animation/rig state (or its thumbnail).
cancel();
disposeRenderer();
};
// dispose() cancels first: an in-flight load's callbacks must not
// render on the disposed renderer or write into the next preview's
// status/animation/rig state (or its thumbnail).
return disposeRenderer;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [previewIndex, filesVersion]);
@@ -0,0 +1,124 @@
import * as THREE from "three";
import { loadPreviewOnCanvas } from "./loadPreviewOnCanvas";
const { renderers, pendingGltfLoads } = vi.hoisted(() => ({
renderers: [] as Array<{
dispose: ReturnType<typeof vi.fn>;
forceContextLoss: ReturnType<typeof vi.fn>;
setAnimationLoop: ReturnType<typeof vi.fn>;
}>,
pendingGltfLoads: [] as Array<(gltf: { scene: unknown }) => void>,
}));
// jsdom has no WebGL; only the lifecycle calls matter here.
vi.mock("three", async (importOriginal) => {
const actual = await importOriginal<typeof import("three")>();
class WebGLRenderer {
dispose = vi.fn();
forceContextLoss = vi.fn();
setAnimationLoop = vi.fn();
constructor() {
renderers.push(this);
}
setSize() {}
render() {}
}
return { ...actual, WebGLRenderer };
});
// Loads stay pending until a test resolves them, to model a load that
// finishes after the preview was superseded.
vi.mock("three/addons/loaders/GLTFLoader.js", () => ({
GLTFLoader: class {
load(_url: string, onLoad: (gltf: { scene: unknown }) => void) {
pendingGltfLoads.push(onLoad);
}
},
}));
vi.mock("@sparkjsdev/spark", async () => {
const { Object3D } = await import("three");
return {
SplatFileType: { SPZ: "spz" },
SplatMesh: class extends Object3D {},
SparkRenderer: class extends Object3D {},
};
});
describe("loadPreviewOnCanvas dispose", () => {
beforeEach(() => {
renderers.length = 0;
pendingGltfLoads.length = 0;
vi.stubGlobal("URL", {
...URL,
createObjectURL: vi.fn(() => "blob:preview"),
revokeObjectURL: vi.fn(),
});
});
it("stops the loop, frees the renderer and revokes the blob URL", () => {
const canvas = attachedCanvas();
const preview = loadPreviewOnCanvas({
file: new File([], "model.glb"),
canvas,
statusCallback: vi.fn(),
});
preview.dispose();
const renderer = renderers[0];
expect(renderer.setAnimationLoop).toHaveBeenLastCalledWith(null);
expect(renderer.dispose).toHaveBeenCalledTimes(1);
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:preview");
});
it("keeps the context while the canvas is reused for the next file", () => {
const preview = loadPreviewOnCanvas({
file: new File([], "model.glb"),
canvas: attachedCanvas(),
statusCallback: vi.fn(),
});
preview.dispose();
expect(renderers[0].forceContextLoss).not.toHaveBeenCalled();
});
it("releases the context once the canvas has left the DOM", () => {
const canvas = attachedCanvas();
const preview = loadPreviewOnCanvas({
file: new File([], "model.glb"),
canvas,
statusCallback: vi.fn(),
});
canvas.remove();
preview.dispose();
expect(renderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
});
it("ignores a model that finishes loading after dispose", () => {
const statusCallback = vi.fn();
const preview = loadPreviewOnCanvas({
file: new File([], "model.glb"),
canvas: attachedCanvas(),
statusCallback,
});
preview.dispose();
const lateModel = new THREE.Group();
lateModel.add(new THREE.Mesh(new THREE.BoxGeometry()));
pendingGltfLoads[0]({ scene: lateModel });
expect(lateModel.children).toHaveLength(1);
expect(statusCallback).not.toHaveBeenCalled();
});
});
function attachedCanvas(): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.getContext = vi.fn(() => null) as never;
document.body.appendChild(canvas);
return canvas;
}
@@ -3,10 +3,12 @@ import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { FontLoader } from "three/addons/loaders/FontLoader.js";
import { TextGeometry } from "three/addons/geometries/TextGeometry.js";
import { SplatFileType, SplatMesh } from "@sparkjsdev/spark";
import { NodeHierarchyHelper } from "@storyteller/ui-viewer-3d";
import { NodeHierarchyHelper, disposeObject3D } from "@storyteller/ui-viewer-3d";
interface LoaderInterface {
file: File;
// Blob URL for `file`, revoked when the preview is disposed.
fileUrl: string;
camera: THREE.PerspectiveCamera;
scene: THREE.Scene;
renderer: THREE.WebGLRenderer;
@@ -31,9 +33,10 @@ interface PreviewReturn {
// Show/hide the skeleton overlay (rigged GLBs only; no-op otherwise).
// Defaults visible for mesh-less models so there's something to see.
setSkeletonVisible: (visible: boolean) => void;
// Call when a newer preview supersedes this one (effect cleanup): any
// still-in-flight load's callbacks become no-ops.
cancel: () => void;
// Call when a newer preview supersedes this one or the preview goes away
// (effect cleanup): in-flight loads become no-ops, the animation loop
// stops, and the scene, renderer and blob URL are freed.
dispose: () => void;
}
export const loadPreviewOnCanvas = ({
@@ -83,6 +86,7 @@ export const loadPreviewOnCanvas = ({
scene.add(light);
let splatMesh: SplatMesh | null = null;
const fileUrl = URL.createObjectURL(file);
// Supersession flag: each preview invocation owns one. The effect that
// created this preview flips it in its cleanup, and every async loader
@@ -90,9 +94,6 @@ export const loadPreviewOnCanvas = ({
// renderer, write status/animation/rig state into the NEXT preview, or
// trigger a thumbnail snapshot of the wrong model.
let cancelled = false;
const cancel = () => {
cancelled = true;
};
const isCancelled = () => cancelled;
// Animation playback state for GLBs with baked clips. The mixer is rooted
@@ -143,6 +144,7 @@ export const loadPreviewOnCanvas = ({
if (file.name.endsWith(".glb")) {
glbLoader({
file,
fileUrl,
scene,
camera,
renderer,
@@ -181,7 +183,15 @@ export const loadPreviewOnCanvas = ({
},
});
} else if (file.name.endsWith(".pmd")) {
pmdLoader({ file, scene, camera, renderer, statusCallback, isCancelled });
pmdLoader({
file,
fileUrl,
scene,
camera,
renderer,
statusCallback,
isCancelled,
});
} else if (
file.name.endsWith(".png") ||
file.name.endsWith(".jpg") ||
@@ -190,6 +200,7 @@ export const loadPreviewOnCanvas = ({
) {
imagePlaneLoader({
file,
fileUrl,
scene,
camera,
renderer,
@@ -242,11 +253,26 @@ export const loadPreviewOnCanvas = ({
};
renderer.setAnimationLoop(animate);
return { renderer, camera, selectAnimation, setSkeletonVisible, cancel };
// The canvas is reused for the next file's preview, which hands that
// renderer this same WebGL context, so the context loss is forced only
// once the canvas has left the DOM (the modal closed).
const dispose = () => {
cancelled = true;
renderer.setAnimationLoop(null);
disposeObject3D(scene);
// A splat still decoding when the preview was superseded never made it
// into the scene.
if (splatMesh && !splatMesh.parent) splatMesh.dispose();
URL.revokeObjectURL(fileUrl);
renderer.dispose();
if (!canvas.isConnected) renderer.forceContextLoss();
};
return { renderer, camera, selectAnimation, setSkeletonVisible, dispose };
};
const glbLoader = ({
file,
fileUrl,
camera,
scene,
renderer,
@@ -257,7 +283,7 @@ const glbLoader = ({
}: LoaderInterface) => {
const loader = new GLTFLoader();
loader.load(
URL.createObjectURL(file),
fileUrl,
(data) => {
if (isCancelled?.()) return;
// Inspect BEFORE re-parenting (the loop below moves children out of
@@ -403,7 +429,7 @@ const pmdLoader = ({
};
const imagePlaneLoader = ({
file,
fileUrl,
scene,
statusCallback,
isCancelled,
@@ -411,7 +437,7 @@ const imagePlaneLoader = ({
const geometry = new THREE.PlaneGeometry(1, 1);
const loader = new THREE.TextureLoader();
const texture = loader.load(
URL.createObjectURL(file),
fileUrl,
undefined,
undefined,
(loaderError) => {
@@ -92,12 +92,13 @@ export const UploadFilesSplat = ({
const [overallProgress, setOverallProgress] = useState<{ current: number; total: number } | null>(null);
const [selectionError, setSelectionError] = useState<string | undefined>();
// Tears down the current preview: stops its loop, cancels in-flight
// loads and frees its scene and renderer (see loadPreviewOnCanvas).
const disposePreviewRef = useRef<(() => void) | null>(null);
const disposeRenderer = () => {
if (rendererRef.current) {
rendererRef.current.setAnimationLoop(null);
rendererRef.current.dispose();
rendererRef.current = null;
}
disposePreviewRef.current?.();
disposePreviewRef.current = null;
rendererRef.current = null;
};
useEffect(() => {
@@ -107,12 +108,13 @@ export const UploadFilesSplat = ({
disposeRenderer();
setPreviewStatus({ type: "init" });
const { renderer, camera } = loadPreviewOnCanvas({
const { renderer, camera, dispose } = loadPreviewOnCanvas({
file: currentFile,
canvas: canvasRef.current,
statusCallback: setPreviewStatus,
});
rendererRef.current = renderer;
disposePreviewRef.current = dispose;
cameraRef.current = camera;
return disposeRenderer;
@@ -1,3 +1,4 @@
export * from './lib/viewer-3d';
export * from './lib/NodeHierarchyHelper';
export * from './lib/disposeObject3D';
@@ -0,0 +1,160 @@
import * as THREE from "three";
import { SparkRenderer, SplatMesh } from "@sparkjsdev/spark";
import { disposeObject3D } from "./disposeObject3D";
// A real SplatMesh / SparkRenderer needs Spark's WASM decoder and a WebGL
// renderer; only `instanceof` and the dispose() calls matter here.
vi.mock("@sparkjsdev/spark", async () => {
const { Object3D } = await import("three");
const accumulator = () => ({ splats: { dispose: vi.fn() } });
return {
SplatMesh: class extends Object3D {
dispose = vi.fn();
},
SparkRenderer: class extends Object3D {
defaultView = { dispose: vi.fn() };
active = accumulator();
freeAccumulators = [accumulator(), accumulator()];
},
};
});
describe("disposeObject3D", () => {
it("disposes nested geometries, materials and their textures", () => {
const map = new THREE.Texture();
const normalMap = new THREE.Texture();
const material = new THREE.MeshStandardMaterial({ map, normalMap });
const geometry = new THREE.BoxGeometry();
const root = new THREE.Group();
const child = new THREE.Group();
child.add(new THREE.Mesh(geometry, material));
root.add(child);
const disposed = trackDisposals([map, normalMap, material, geometry]);
disposeObject3D(root);
expect(disposed).toEqual(new Set([map, normalMap, material, geometry]));
});
it("disposes every material of a multi-material mesh", () => {
const first = new THREE.MeshBasicMaterial({ map: new THREE.Texture() });
const second = new THREE.MeshBasicMaterial();
const mesh = new THREE.Mesh(new THREE.BoxGeometry(), [first, second]);
const disposed = trackDisposals([first, second, first.map!]);
disposeObject3D(mesh);
expect(disposed).toEqual(new Set([first, second, first.map]));
});
it("disposes a material shared by several meshes once", () => {
const material = new THREE.MeshBasicMaterial();
const root = new THREE.Group();
root.add(new THREE.Mesh(new THREE.BoxGeometry(), material));
root.add(new THREE.Mesh(new THREE.BoxGeometry(), material));
const onDispose = vi.fn();
material.addEventListener("dispose", onDispose);
disposeObject3D(root);
expect(onDispose).toHaveBeenCalledTimes(1);
});
it("disposes skinned mesh skeletons", () => {
const bone = new THREE.Bone();
const skeleton = new THREE.Skeleton([bone]);
const mesh = new THREE.SkinnedMesh(
new THREE.BoxGeometry(),
new THREE.MeshBasicMaterial(),
);
mesh.add(bone);
mesh.bind(skeleton);
const dispose = vi.spyOn(skeleton, "dispose");
disposeObject3D(mesh);
expect(dispose).toHaveBeenCalledTimes(1);
});
it("frees splat buffers", () => {
const splat = new SplatMesh();
const root = new THREE.Group();
root.add(splat);
disposeObject3D(root);
expect(splat.dispose).toHaveBeenCalledTimes(1);
});
it("frees a SparkRenderer's viewpoint and accumulators", () => {
const spark = new SparkRenderer({} as never);
const root = new THREE.Group();
root.add(spark);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { freeAccumulators } = spark as any;
disposeObject3D(root);
expect(spark.defaultView.dispose).toHaveBeenCalledTimes(1);
expect(spark.active.splats.dispose).toHaveBeenCalledTimes(1);
for (const accumulator of freeAccumulators) {
expect(accumulator.splats.dispose).toHaveBeenCalledTimes(1);
}
});
it("disposes textures held in ShaderMaterial uniforms", () => {
const normals = new THREE.Texture();
const mirror = new THREE.WebGLRenderTarget(4, 4).texture;
const layers = [new THREE.Texture(), new THREE.Texture()];
const material = new THREE.ShaderMaterial({
uniforms: {
normalSampler: { value: normals },
mirrorSampler: { value: mirror },
layerSamplers: { value: layers },
size: { value: 1.0 },
},
});
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(), material);
const disposed = trackDisposals([normals, mirror, ...layers]);
disposeObject3D(mesh);
expect(disposed).toEqual(new Set([normals, mirror, ...layers]));
});
it("disposes a texture shared by a property and a uniform once", () => {
const video = new THREE.Texture();
const material = new THREE.ShaderMaterial({
uniforms: { map: { value: video } },
});
// ChromaKeyMaterial keeps its VideoTexture both here and in a uniform.
(material as unknown as { texture: THREE.Texture }).texture = video;
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(), material);
const onDispose = vi.fn();
video.addEventListener("dispose", onDispose);
disposeObject3D(mesh);
expect(onDispose).toHaveBeenCalledTimes(1);
});
it("skips objects without geometry or material", () => {
const root = new THREE.Group();
root.add(new THREE.DirectionalLight());
root.add(new THREE.PerspectiveCamera());
expect(() => disposeObject3D(root)).not.toThrow();
});
});
// Records which resources dispatched their "dispose" event.
function trackDisposals(
resources: Array<THREE.Texture | THREE.Material | THREE.BufferGeometry>,
): Set<unknown> {
const disposed = new Set<unknown>();
for (const resource of resources) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(resource as any).addEventListener("dispose", () => disposed.add(resource));
}
return disposed;
}
@@ -0,0 +1,93 @@
import * as THREE from "three";
import { SparkRenderer, SplatMesh } from "@sparkjsdev/spark";
// Free the GPU resources held by an object tree that is leaving the scene
// for good: geometries, materials, the textures those materials reference
// (Material.dispose() does not free its maps), skeleton bone textures and
// Spark splat buffers. Call it after the tree has been removed.
//
// Textures are found both as material properties (map, normalMap, ...) and
// as ShaderMaterial uniforms (Water's normal and mirror samplers, Spark's
// splat textures). Water keeps its mirror render target in a closure, so
// only that target's color texture can be reached; its framebuffer and depth
// buffer stay allocated.
//
// Geometry, material and texture disposal is safe even if one of them is
// shared with an object that stays — three.js re-uploads it the next time
// it renders. Splat buffers are not re-creatable, but every SplatMesh owns
// its own (scene.ts builds one per load), and undo re-loads deleted
// objects from their snapshot rather than reusing the instance.
export function disposeObject3D(root: THREE.Object3D): void {
root.traverse((child) => {
if (child instanceof SplatMesh) child.dispose();
if (child instanceof SparkRenderer) disposeSparkRenderer(child);
if (child instanceof THREE.SkinnedMesh) child.skeleton?.dispose();
(child as Partial<THREE.Mesh>).geometry?.dispose();
});
const materials = collectMaterials(root);
const textures = collectMaterialTextures(materials);
for (const material of materials) material.dispose();
for (const texture of textures) texture.dispose();
}
// Every texture the tree's materials reference, as material properties or
// ShaderMaterial uniforms, deduplicated.
export function collectObjectTextures(
root: THREE.Object3D,
): Set<THREE.Texture> {
return collectMaterialTextures(collectMaterials(root));
}
// A SparkRenderer is added to the scene automatically the first time a
// splat renders. Its viewpoint render targets and splat accumulators are
// not reachable from its material, so free them explicitly. The viewpoint
// goes first: disposing it returns its accumulators to the free list.
function disposeSparkRenderer(spark: SparkRenderer): void {
spark.defaultView.dispose();
spark.active.splats.dispose();
const { freeAccumulators } = spark as unknown as {
freeAccumulators: SparkRenderer["active"][];
};
for (const accumulator of freeAccumulators) accumulator.splats.dispose();
}
function collectMaterials(root: THREE.Object3D): Set<THREE.Material> {
const materials = new Set<THREE.Material>();
root.traverse((child) => {
const { material } = child as Partial<THREE.Mesh>;
const childMaterials = Array.isArray(material) ? material : [material];
for (const childMaterial of childMaterials) {
if (childMaterial) materials.add(childMaterial);
}
});
return materials;
}
function collectMaterialTextures(
materials: Iterable<THREE.Material>,
): Set<THREE.Texture> {
const textures = new Set<THREE.Texture>();
for (const material of materials) {
addTextures(Object.values(material), textures);
if (material instanceof THREE.ShaderMaterial) {
addTextures(
Object.values(material.uniforms).map((uniform) => uniform?.value),
textures,
);
}
}
return textures;
}
function addTextures(values: unknown[], into: Set<THREE.Texture>): void {
for (const value of values) {
if (value instanceof THREE.Texture) {
into.add(value);
} else if (Array.isArray(value)) {
for (const item of value) {
if (item instanceof THREE.Texture) into.add(item);
}
}
}
}
@@ -2,11 +2,23 @@ import { render } from '@testing-library/react';
import StorytellerUiViewer3d from './viewer-3d';
// jsdom has no ResizeObserver; the viewer only needs it to exist.
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
disconnect() {}
},
);
describe('StorytellerUiViewer3d', () => {
it('should render successfully', () => {
const { baseElement } = render(<StorytellerUiViewer3d />);
expect(baseElement).toBeTruthy();
});
it('unmounts cleanly before the scene ever initialized', () => {
const { unmount } = render(<StorytellerUiViewer3d />);
expect(() => unmount()).not.toThrow();
});
});
@@ -9,6 +9,7 @@ import {
NodeHierarchyHelper,
createRigHelper,
} from "./NodeHierarchyHelper";
import { disposeObject3D } from "./disposeObject3D";
// Dropdown value for the "no animation" choice (models rest in their bind /
// T-pose). Clip values are their stringified index.
@@ -277,20 +278,14 @@ export function Viewer3D({
) {
container.removeChild(rendererRef.current.domElement);
}
rendererRef.current?.dispose();
controlsRef.current?.dispose();
if (cubeRef.current) {
const cube = cubeRef.current;
if (cube.geometry) cube.geometry.dispose();
if (cube.material) {
if (Array.isArray(cube.material)) {
cube.material.forEach((m) => m.dispose());
} else {
cube.material.dispose();
}
}
}
// Free everything still in the scene (placeholder cube, grid, any
// model, Spark's auto-added splat renderer), then the context itself.
// The canvas is ours and already detached, so forcing the context
// loss frees it now instead of whenever the canvas is collected.
if (sceneRef.current) disposeObject3D(sceneRef.current);
rendererRef.current?.dispose();
rendererRef.current?.forceContextLoss();
};
}, []);
@@ -407,11 +402,19 @@ export function Viewer3D({
const SPLAT_EXTENSIONS = ["spz", "ply", "splat", "ksplat"];
const GLTF_EXTENSIONS = ["glb", "gltf", ""];
// Set by this effect's cleanup. A model that finishes loading after the
// URL changed or the viewer unmounted is freed instead of shown.
let cancelled = false;
if (SPLAT_EXTENSIONS.includes(urlExtension)) {
console.log(`[Viewer3D] splat format detected (.${urlExtension})`);
new SplatMesh({
url: modelUrl,
onLoad: (mesh) => {
if (cancelled) {
mesh.dispose();
return;
}
mesh.rotation.z = Math.PI;
mesh.position.y = 1;
scene.add(mesh);
@@ -450,6 +453,10 @@ export function Viewer3D({
loader.load(
modelUrl,
(gltf) => {
if (cancelled) {
disposeObject3D(gltf.scene);
return;
}
console.log("[Viewer3D] Model loaded successfully");
const model = gltf.scene;
onModelLoaded(model);
@@ -503,10 +510,12 @@ export function Viewer3D({
}
return () => {
cancelled = true;
stopAnimations();
removeSkeletonHelper();
if (loadedModelRef.current && sceneRef.current) {
sceneRef.current.remove(loadedModelRef.current);
if (loadedModelRef.current) {
loadedModelRef.current.removeFromParent();
disposeObject3D(loadedModelRef.current);
loadedModelRef.current = null;
}
};