Compare commits

..
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 a8ef87a097 Track which app card or icon visitors pick on the Apps pages
Adds an app_select event to the /apps icon row, the lineup cards and the
"More from the family" sibling cards, so link_location shows which entry
point sent visitors to each app page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:51:34 +07:00
bflatasticandClaude Opus 5.5 084aa1b4b4 Add Google Analytics to the marketing site
Loads GA4 (gtag.js) when NEXT_PUBLIC_GA_MEASUREMENT_ID is set and sends
events for the site's conversion paths: desktop/Crafting App downloads,
webapp and CTA clicks, social and contact links, pricing checkout,
the H3 promptbox and auth gate, beta leads, video plays, and shares.

Most link clicks are classified by href in one delegated listener, so
components only change where an action isn't a plain link. Analytics
stays off when the env var is unset (local dev, deploy previews).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:34:44 +07:00
44 changed files with 145 additions and 1078 deletions

No files matched your search

@@ -32,18 +32,6 @@ 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.

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.

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: 49 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 74 KiB

@@ -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-pdfcraft { --app: #12a58a; --app-ink: #0a7563; }
.app-printcraft { --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-pdfcraft { --app-ink: #3fd6b8; }
:root[data-theme="dark"] .app-printcraft { --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-pdfcraft { --app-ink: #3fd6b8; }
:root:not([data-theme="light"]) .app-printcraft { --app-ink: #3fd6b8; }
:root:not([data-theme="light"]) .app-effectcraft { --app-ink: #ff7ab8; }
:root:not([data-theme="light"]) .app-designcraft { --app-ink: #a6e04a; }
}
@@ -32,12 +32,11 @@ 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 pair up, and an odd one out
// leads at full width; portrait pages (layout spreads) sit four across.
// The hero carries shot 01. Window captures lead with a full-width shot,
// then pair; 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
@@ -56,7 +55,7 @@ export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
<figure
key={shot.file}
data-reveal
className={twMerge("bg-bg", leadFullWidth && i === 0 && "md:col-span-2")}
className={twMerge("bg-bg", !portrait && i === 0 && "md:col-span-2")}
>
<a
href={craftShotSourceUrl(app, shot)}
@@ -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.1";
export const CRAFTING_APPS_LATEST_VERSION = "0.2.0";
export type CraftDesktopPlatform = "macOS" | "Windows" | "Linux";
@@ -43,8 +43,6 @@ 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[];
};
@@ -173,35 +171,15 @@ 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: craftRelease("0.3.0", WITH_WINDOWS_ARM64),
vectorcraft: craftRelease("0.4.0", WITH_WINDOWS_ARM64),
photocraft: LATEST,
vectorcraft: LATEST,
filmcraft: LATEST,
lightcraft: LATEST,
// 0.2.1 shipped before the PrintCraft → PdfCraft rename.
pdfcraft: { ...LATEST, fileSlug: "printcraft" },
effectcraft: craftRelease("0.4.0", WITH_WINDOWS_ARM64),
printcraft: LATEST,
effectcraft: LATEST,
designcraft: LATEST,
};
@@ -25,7 +25,7 @@ export type CraftAppSlug =
| "vectorcraft"
| "filmcraft"
| "lightcraft"
| "pdfcraft"
| "printcraft"
| "effectcraft"
| "designcraft";
@@ -177,31 +177,25 @@ export const CRAFTING_APPS: CraftApp[] = [
file: "hero.webp",
source: "shot-1-neon.png",
caption: "Neon Drive — made in VectorCraft",
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",
alt: "VectorCraft editing a synthwave poster titled Neon Drive with an outer glow in the Appearance panel",
},
{
file: "ribbons.webp",
source: "shot-2-ribbons.png",
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",
caption: "Live blends between editable spines",
alt: "VectorCraft showing live blends: 70 smooth-color steps between two ribbon paths",
},
{
file: "sheet.webp",
source: "shot-3-sheet.png",
caption: "Booleans, mesh, repeat and envelope",
alt: "VectorCraft in its light theme with four artboards: shape booleans, a gradient mesh, a live radial repeat and an envelope distort",
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",
},
],
},
@@ -334,8 +328,8 @@ export const CRAFTING_APPS: CraftApp[] = [
],
},
{
slug: "pdfcraft",
prefix: "Pdf",
slug: "printcraft",
prefix: "Print",
category: "PDF workbench",
status: "Early alpha",
headline: ["The ", "open-source", " PDF workbench."],
@@ -373,27 +367,27 @@ export const CRAFTING_APPS: CraftApp[] = [
shots: [
{
file: "hero.webp",
source: "pdfcraft-viewer.png",
source: "printcraft-viewer.png",
caption: "Viewer with threaded comments",
alt: "PdfCraft showing a showcase PDF cover with the All tools panel and threaded comments",
alt: "PrintCraft showing a showcase PDF cover with the tools sidebar and threaded comments",
},
{
file: "organize.webp",
source: "pdfcraft-organize.png",
source: "printcraft-organize.png",
caption: "Organize pages like cards",
alt: "PdfCraft page organizer with three pages selected and the page tools in the toolbar above",
alt: "PrintCraft page organizer with three pages selected for rotate, delete and extract",
},
{
file: "palette.webp",
source: "pdfcraft-palette.png",
source: "printcraft-palette.png",
caption: "A command palette for every tool",
alt: "PdfCraft command palette searching for page and listing the page tools",
alt: "PrintCraft command palette filtering page tools by the word page",
},
{
file: "twoup.webp",
source: "pdfcraft-twoup.png",
source: "printcraft-twoup.png",
caption: "Two-up Read mode, dark theme",
alt: "PdfCraft two-up Read mode in the dark theme showing typeset specimen pages",
alt: "PrintCraft two-up Read mode in the dark theme showing typeset specimen pages",
},
],
},
@@ -655,7 +649,7 @@ export function craftReleaseDownloads(
const base = `${craftAppRepo(app)}/releases/download/${craftReleaseTag(release)}`;
return release.assets.map(({ file, ...asset }) => {
const fileName = file
.replaceAll("{slug}", release.fileSlug ?? app.slug)
.replaceAll("{slug}", app.slug)
.replaceAll("{version}", release.version);
return { ...asset, fileName, href: `${base}/${fileName}` };
});
@@ -3,7 +3,6 @@ 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,
@@ -227,17 +226,11 @@ export const Model3DOverlay = React.forwardRef<Model3DOverlayHandle, Model3DOver
front.position.set(0, 4, 10);
scene.add(front);
// 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;
// Load model
const loader = new GLTFLoader();
loader.load(
node.modelUrl!,
(gltf) => {
if (cancelled) {
disposeObject3D(gltf.scene);
return;
}
const model = gltf.scene;
// Auto-fit + apply user scale
@@ -268,15 +261,18 @@ export const Model3DOverlay = React.forwardRef<Model3DOverlayHandle, Model3DOver
animate();
return () => {
cancelled = true;
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
controls.dispose();
disposeObject3D(scene);
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());
}
});
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]);
@@ -1,82 +0,0 @@
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,6 +1,5 @@
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 };
@@ -77,16 +76,18 @@ 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 {
disposeObject3D(scene);
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());
}
});
renderer.dispose();
renderer.forceContextLoss();
}
export async function render3DModelToDataUrl(
@@ -88,9 +88,6 @@
},
{
"path": "../modal/tsconfig.lib.json"
},
{
"path": "../viewer-3d/tsconfig.lib.json"
}
]
}
@@ -1127,27 +1127,11 @@ 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));
@@ -1161,10 +1145,4 @@ class Editor {
}
}
function releaseContextIfDetached(renderer: THREE.WebGLRenderer | undefined) {
if (renderer && !renderer.domElement.isConnected) {
renderer.forceContextLoss();
}
}
export default Editor;
@@ -1,38 +0,0 @@
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,12 +100,4 @@ 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,11 +148,45 @@ function removeObject3D(object3D) {
return;
}
// 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.
// Finally remove the object from the scene
this.scene.scene.remove(obj);
this.scene.disposeObject(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.deps.bus.emit(new ObjectRemovedEvent(uuid));
this.deps.clearSelected();
@@ -1,167 +0,0 @@
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,10 +13,6 @@ 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
@@ -138,64 +134,16 @@ class Scene {
}
clear() {
this.disposeContents();
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._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),
@@ -1170,9 +1118,6 @@ 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 {
@@ -1327,12 +1272,4 @@ 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;
@@ -1,91 +0,0 @@
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;
// Free the outgoing scene before loading over it; removing the
// children alone left all of their GPU resources allocated.
this.scene.disposeContents();
while (this.scene.scene.children.length > 0) {
this.scene.scene.remove(this.scene.scene.children[0]);
}
// Warm Scene's URL cache up front so per-asset loadObject() calls
// never hit the network for token→URL resolution. One batch call
@@ -229,10 +229,7 @@ export class StoryTellerProxyScene {
);
const settled = await Promise.allSettled(tasks);
if (ticket?.cancelled) {
this.discardLoadedObjects(settled);
return;
}
if (ticket?.cancelled) return;
// Synchronous transform-application pass. We walk results in
// original JSON order to preserve scene.children insertion
@@ -298,21 +295,4 @@ 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,9 +124,6 @@
},
{
"path": "../../icons/tsconfig.lib.json"
},
{
"path": "../viewer-3d/tsconfig.lib.json"
}
]
}
@@ -160,13 +160,12 @@ 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 = () => {
disposePreviewRef.current?.();
disposePreviewRef.current = null;
rendererRef.current = null;
if (rendererRef.current) {
rendererRef.current.setAnimationLoop(null);
rendererRef.current.dispose();
rendererRef.current = null;
}
};
// Normalize an FBX to GLB in the background. Completion swaps the entry's
@@ -238,7 +237,7 @@ export const UploadFiles3D = ({
return;
}
const { renderer, camera, selectAnimation, setSkeletonVisible, dispose } =
const { renderer, camera, selectAnimation, setSkeletonVisible, cancel } =
loadPreviewOnCanvas({
file: currentFile,
canvas: canvasRef.current,
@@ -251,15 +250,17 @@ export const UploadFiles3D = ({
},
});
rendererRef.current = renderer;
disposePreviewRef.current = dispose;
cameraRef.current = camera;
selectAnimationRef.current = selectAnimation;
setSkeletonVisibleRef.current = setSkeletonVisible;
// 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;
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();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [previewIndex, filesVersion]);
@@ -1,124 +0,0 @@
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,12 +3,10 @@ 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, disposeObject3D } from "@storyteller/ui-viewer-3d";
import { NodeHierarchyHelper } 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;
@@ -33,10 +31,9 @@ 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 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;
// Call when a newer preview supersedes this one (effect cleanup): any
// still-in-flight load's callbacks become no-ops.
cancel: () => void;
}
export const loadPreviewOnCanvas = ({
@@ -86,7 +83,6 @@ 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
@@ -94,6 +90,9 @@ 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
@@ -144,7 +143,6 @@ export const loadPreviewOnCanvas = ({
if (file.name.endsWith(".glb")) {
glbLoader({
file,
fileUrl,
scene,
camera,
renderer,
@@ -183,15 +181,7 @@ export const loadPreviewOnCanvas = ({
},
});
} else if (file.name.endsWith(".pmd")) {
pmdLoader({
file,
fileUrl,
scene,
camera,
renderer,
statusCallback,
isCancelled,
});
pmdLoader({ file, scene, camera, renderer, statusCallback, isCancelled });
} else if (
file.name.endsWith(".png") ||
file.name.endsWith(".jpg") ||
@@ -200,7 +190,6 @@ export const loadPreviewOnCanvas = ({
) {
imagePlaneLoader({
file,
fileUrl,
scene,
camera,
renderer,
@@ -253,26 +242,11 @@ export const loadPreviewOnCanvas = ({
};
renderer.setAnimationLoop(animate);
// 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 };
return { renderer, camera, selectAnimation, setSkeletonVisible, cancel };
};
const glbLoader = ({
fileUrl,
file,
camera,
scene,
renderer,
@@ -283,7 +257,7 @@ const glbLoader = ({
}: LoaderInterface) => {
const loader = new GLTFLoader();
loader.load(
fileUrl,
URL.createObjectURL(file),
(data) => {
if (isCancelled?.()) return;
// Inspect BEFORE re-parenting (the loop below moves children out of
@@ -429,7 +403,7 @@ const pmdLoader = ({
};
const imagePlaneLoader = ({
fileUrl,
file,
scene,
statusCallback,
isCancelled,
@@ -437,7 +411,7 @@ const imagePlaneLoader = ({
const geometry = new THREE.PlaneGeometry(1, 1);
const loader = new THREE.TextureLoader();
const texture = loader.load(
fileUrl,
URL.createObjectURL(file),
undefined,
undefined,
(loaderError) => {
@@ -92,13 +92,12 @@ 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 = () => {
disposePreviewRef.current?.();
disposePreviewRef.current = null;
rendererRef.current = null;
if (rendererRef.current) {
rendererRef.current.setAnimationLoop(null);
rendererRef.current.dispose();
rendererRef.current = null;
}
};
useEffect(() => {
@@ -108,13 +107,12 @@ export const UploadFilesSplat = ({
disposeRenderer();
setPreviewStatus({ type: "init" });
const { renderer, camera, dispose } = loadPreviewOnCanvas({
const { renderer, camera } = loadPreviewOnCanvas({
file: currentFile,
canvas: canvasRef.current,
statusCallback: setPreviewStatus,
});
rendererRef.current = renderer;
disposePreviewRef.current = dispose;
cameraRef.current = camera;
return disposeRenderer;
@@ -1,4 +1,3 @@
export * from './lib/viewer-3d';
export * from './lib/NodeHierarchyHelper';
export * from './lib/disposeObject3D';
@@ -1,160 +0,0 @@
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;
}
@@ -1,93 +0,0 @@
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,23 +2,11 @@ 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,7 +9,6 @@ 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.
@@ -278,14 +277,20 @@ export function Viewer3D({
) {
container.removeChild(rendererRef.current.domElement);
}
controlsRef.current?.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();
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();
}
}
}
};
}, []);
@@ -402,19 +407,11 @@ 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);
@@ -453,10 +450,6 @@ 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);
@@ -510,12 +503,10 @@ export function Viewer3D({
}
return () => {
cancelled = true;
stopAnimations();
removeSkeletonHelper();
if (loadedModelRef.current) {
loadedModelRef.current.removeFromParent();
disposeObject3D(loadedModelRef.current);
if (loadedModelRef.current && sceneRef.current) {
sceneRef.current.remove(loadedModelRef.current);
loadedModelRef.current = null;
}
};