@fungi.computer/sprite 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/player.js ADDED
@@ -0,0 +1,368 @@
1
+ import * as Cause from "effect/Cause";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Exit from "effect/Exit";
4
+ import * as Scope from "effect/Scope";
5
+ import * as z from "zod";
6
+ import { createAssetReader } from "./assets.js";
7
+ import { resolveAppearance } from "./controls.js";
8
+ import { createRenderStyleLayer, renderStyleSchema, } from "./render-style.js";
9
+ import { characterSchema, localCharacterSourceSchema, } from "./document.js";
10
+ const resolveSource = (source, baseURL) => {
11
+ const local = localCharacterSourceSchema.safeParse(source);
12
+ if (local.success)
13
+ return { kind: "local", key: local.data.local };
14
+ return {
15
+ kind: "remote",
16
+ url: new URL(z.string().parse(source), baseURL).href,
17
+ };
18
+ };
19
+ const asError = (cause) => cause instanceof Error ? cause : new Error(String(cause), { cause });
20
+ const exitError = (exit) => Exit.isFailure(exit)
21
+ ? asError(Cause.squash(exit.cause))
22
+ : new Error("Unexpected Sprite success");
23
+ /** Stable shell service; each mounted player owns its own selection. */
24
+ export function createSprite(options) {
25
+ const maxPixels = options.maxPixels ?? 4_194_304;
26
+ if (!Number.isSafeInteger(maxPixels) || maxPixels <= 0)
27
+ throw new Error("Sprite pixel limit must be a positive safe integer");
28
+ const assetPolicy = options.assets ?? {
29
+ baseURL: "https://sprite.invalid/",
30
+ allowedOrigins: [],
31
+ maxBytes: 1,
32
+ maxTotalBytes: 1,
33
+ maxRequests: 1,
34
+ };
35
+ const newAssetReader = createAssetReader(assetPolicy);
36
+ const owners = new WeakSet();
37
+ const priorCleanup = new WeakMap();
38
+ const sourceKey = (source) => {
39
+ const resolved = resolveSource(source, assetPolicy.baseURL);
40
+ return resolved.kind === "remote" ? resolved.url : `local:${resolved.key}`;
41
+ };
42
+ return {
43
+ mount(target) {
44
+ if (owners.has(target))
45
+ throw new Error("A Sprite already owns this target");
46
+ owners.add(target);
47
+ const mountScope = Effect.runSync(Scope.make());
48
+ const listeners = new Set();
49
+ let state = { status: "empty" };
50
+ let desired;
51
+ let selection;
52
+ let revision = 0;
53
+ let dirty = false;
54
+ let worker;
55
+ let retiring;
56
+ let closing;
57
+ let disposed = false;
58
+ let paused = false;
59
+ const view = target.ownerDocument.defaultView;
60
+ const motion = view?.matchMedia("(prefers-reduced-motion: reduce)");
61
+ const publish = (next) => {
62
+ if (disposed && next.status !== "destroyed")
63
+ return;
64
+ state = next;
65
+ for (const listener of listeners) {
66
+ try {
67
+ listener(next);
68
+ }
69
+ catch (error) {
70
+ console.error("Sprite state observer failed", error);
71
+ }
72
+ }
73
+ };
74
+ const failSelection = (error) => {
75
+ selection?.controller.abort();
76
+ selection?.holder.remove();
77
+ publish({ status: "error", error });
78
+ void closeSelection().catch((cleanupError) => {
79
+ publish({
80
+ status: "error",
81
+ error: new AggregateError([error, cleanupError], "Avatar failed and cleanup also failed"),
82
+ });
83
+ });
84
+ };
85
+ const styles = createRenderStyleLayer(target);
86
+ const suspend = () => {
87
+ const still = paused || target.ownerDocument.hidden || motion?.matches === true;
88
+ styles.still(still);
89
+ try {
90
+ selection?.native?.pause(still);
91
+ }
92
+ catch (error) {
93
+ failSelection(asError(error));
94
+ }
95
+ };
96
+ Effect.runSync(Scope.addFinalizer(mountScope, Effect.sync(() => {
97
+ target.ownerDocument.removeEventListener("visibilitychange", suspend);
98
+ motion?.removeEventListener("change", suspend);
99
+ styles.dispose();
100
+ })));
101
+ target.ownerDocument.addEventListener("visibilitychange", suspend);
102
+ motion?.addEventListener("change", suspend);
103
+ const closeSelection = async () => {
104
+ if (retiring)
105
+ return retiring;
106
+ const old = selection;
107
+ if (!old)
108
+ return;
109
+ selection = undefined;
110
+ old.controller.abort();
111
+ old.holder.remove();
112
+ const cleanup = Effect.runPromiseExit(Scope.close(old.scope, Exit.void)).then((exit) => {
113
+ if (Exit.isFailure(exit))
114
+ throw exitError(exit);
115
+ });
116
+ retiring = cleanup;
117
+ try {
118
+ await cleanup;
119
+ }
120
+ finally {
121
+ if (retiring === cleanup)
122
+ retiring = undefined;
123
+ }
124
+ };
125
+ const apply = (current) => {
126
+ const native = current.native;
127
+ const document = current.document;
128
+ const request = desired;
129
+ if (!native || !document || !request || selection !== current)
130
+ return;
131
+ const activity = request.activity ?? "idle";
132
+ const expression = request.expression ?? native.expressions[0] ?? null;
133
+ let appearance;
134
+ let renderStyle;
135
+ try {
136
+ appearance = resolveAppearance(document.controls, request.appearance);
137
+ renderStyle = renderStyleSchema.parse(request.renderStyle ?? "plain");
138
+ }
139
+ catch (error) {
140
+ const updateError = asError(error);
141
+ if (state.status === "ready") {
142
+ if (state.updateError?.message !== updateError.message)
143
+ publish({ ...state, updateError });
144
+ }
145
+ else
146
+ failSelection(updateError);
147
+ return;
148
+ }
149
+ const effective = {
150
+ requestedActivity: activity,
151
+ requestedExpression: expression,
152
+ activity: native.activities.includes(activity) ? activity : "idle",
153
+ expression: expression !== null && native.expressions.includes(expression)
154
+ ? expression
155
+ : (native.expressions[0] ?? null),
156
+ appearance,
157
+ renderStyle,
158
+ fallback: !native.activities.includes(activity) ||
159
+ (expression !== null && !native.expressions.includes(expression)),
160
+ };
161
+ const key = JSON.stringify([
162
+ effective.activity,
163
+ effective.expression,
164
+ effective.appearance,
165
+ ]);
166
+ styles.set(renderStyle, current.holder);
167
+ if (key === current.appliedKey &&
168
+ state.status === "ready" &&
169
+ state.presentation.renderStyle === renderStyle &&
170
+ state.updateError === undefined &&
171
+ state.presentation.requestedActivity === activity &&
172
+ state.presentation.requestedExpression === expression)
173
+ return;
174
+ try {
175
+ if (key !== current.appliedKey)
176
+ native.set(effective);
177
+ current.appliedKey = key;
178
+ suspend();
179
+ if (selection === current)
180
+ publish({ status: "ready", document, presentation: effective });
181
+ }
182
+ catch (error) {
183
+ failSelection(asError(error));
184
+ }
185
+ };
186
+ const openSelection = async (request, key, token) => {
187
+ const readAsset = newAssetReader();
188
+ const scope = Effect.runSync(Scope.make());
189
+ const controller = new AbortController();
190
+ const holder = target.ownerDocument.createElement("div");
191
+ holder.style.cssText = "width:100%;height:100%;position:relative";
192
+ const current = { key, scope, controller, holder };
193
+ selection = current;
194
+ Effect.runSync(Scope.addFinalizer(scope, Effect.sync(() => {
195
+ holder.remove();
196
+ })));
197
+ // Canvas engines need a real layout box before native initialization.
198
+ target.append(holder);
199
+ const program = Effect.gen(function* () {
200
+ let document;
201
+ let render;
202
+ let base = assetPolicy.baseURL;
203
+ const source = resolveSource(request.src, assetPolicy.baseURL);
204
+ if (source.kind === "remote") {
205
+ const asset = yield* Effect.tryPromise({
206
+ try: () => readAsset(source.url, controller.signal),
207
+ catch: asError,
208
+ });
209
+ base = asset.url;
210
+ document = characterSchema.parse(JSON.parse(new TextDecoder().decode(asset.bytes)));
211
+ const loader = options.renderers &&
212
+ Object.hasOwn(options.renderers, document.renderer)
213
+ ? options.renderers[document.renderer]
214
+ : undefined;
215
+ if (!loader)
216
+ throw new Error(`Renderer is not installed: ${document.renderer}`);
217
+ render = (yield* Effect.tryPromise({ try: loader, catch: asError }))
218
+ .default;
219
+ }
220
+ else {
221
+ const local = source.key;
222
+ const loader = options.characters && Object.hasOwn(options.characters, local)
223
+ ? options.characters[local]
224
+ : undefined;
225
+ if (!loader)
226
+ throw new Error(`Character is not installed: ${local}`);
227
+ const character = yield* Effect.tryPromise({
228
+ try: loader,
229
+ catch: asError,
230
+ });
231
+ document = characterSchema.parse(character.document);
232
+ render = character.render;
233
+ }
234
+ controller.signal.throwIfAborted();
235
+ const assetURL = (reference) => Effect.gen(function* () {
236
+ const asset = yield* Effect.tryPromise({
237
+ try: () => readAsset(reference, controller.signal, base),
238
+ catch: asError,
239
+ });
240
+ return yield* Effect.acquireRelease(Effect.sync(() => URL.createObjectURL(new Blob([asset.bytes], { type: asset.contentType }))), (url) => Effect.sync(() => {
241
+ URL.revokeObjectURL(url);
242
+ }));
243
+ });
244
+ const native = yield* render({
245
+ target: holder,
246
+ document,
247
+ signal: controller.signal,
248
+ maxPixels,
249
+ reportError: (error) => {
250
+ if (selection === current && !controller.signal.aborted)
251
+ failSelection(error);
252
+ },
253
+ assetURL,
254
+ });
255
+ if (!native.activities.includes("idle"))
256
+ throw new Error("Character has no idle fallback");
257
+ native.pause(true);
258
+ return { native, document };
259
+ }).pipe(Scope.provide(scope));
260
+ const exit = await Effect.runPromiseExit(program, {
261
+ signal: controller.signal,
262
+ });
263
+ if (disposed || token !== revision || selection !== current) {
264
+ return;
265
+ }
266
+ if (Exit.isFailure(exit)) {
267
+ const error = exitError(exit);
268
+ try {
269
+ await closeSelection();
270
+ publish({ status: "error", error });
271
+ }
272
+ catch (cleanupError) {
273
+ publish({
274
+ status: "error",
275
+ error: new AggregateError([error, cleanupError], "Avatar load and cleanup failed"),
276
+ });
277
+ }
278
+ return;
279
+ }
280
+ current.native = exit.value.native;
281
+ current.document = exit.value.document;
282
+ apply(current);
283
+ };
284
+ const reconcile = async () => {
285
+ const superseded = (token) => disposed || token !== revision;
286
+ while (dirty && !disposed) {
287
+ dirty = false;
288
+ const token = revision;
289
+ await closeSelection();
290
+ if (superseded(token) || !desired)
291
+ continue;
292
+ const prior = await priorCleanup.get(target);
293
+ if (prior?.kind === "failed")
294
+ throw prior.error;
295
+ if (superseded(token))
296
+ continue;
297
+ publish({ status: "loading" });
298
+ await openSelection(desired, sourceKey(desired.src), token);
299
+ }
300
+ };
301
+ const schedule = () => {
302
+ dirty = true;
303
+ selection?.controller.abort();
304
+ selection?.holder.remove();
305
+ if (worker)
306
+ return;
307
+ worker = reconcile()
308
+ .catch((error) => {
309
+ publish({ status: "error", error: asError(error) });
310
+ })
311
+ .finally(() => {
312
+ worker = undefined;
313
+ if (dirty && !disposed)
314
+ schedule();
315
+ });
316
+ };
317
+ return {
318
+ get state() {
319
+ return state;
320
+ },
321
+ subscribe(listener) {
322
+ listeners.add(listener);
323
+ return () => listeners.delete(listener);
324
+ },
325
+ set(next) {
326
+ if (disposed)
327
+ throw new Error("Sprite is destroyed");
328
+ desired = next;
329
+ const key = sourceKey(next.src);
330
+ if (selection?.key === key && state.status === "ready")
331
+ apply(selection);
332
+ else if (selection?.key !== key || state.status === "error") {
333
+ revision += 1;
334
+ schedule();
335
+ }
336
+ },
337
+ pause(value) {
338
+ if (disposed)
339
+ throw new Error("Sprite is destroyed");
340
+ paused = value;
341
+ suspend();
342
+ },
343
+ dispose() {
344
+ if (closing)
345
+ return closing;
346
+ disposed = true;
347
+ revision += 1;
348
+ owners.delete(target);
349
+ selection?.controller.abort();
350
+ selection?.holder.remove();
351
+ publish({ status: "destroyed" });
352
+ closing = (async () => {
353
+ await worker;
354
+ await closeSelection();
355
+ const exit = await Effect.runPromiseExit(Scope.close(mountScope, Exit.void));
356
+ if (Exit.isFailure(exit))
357
+ throw exitError(exit);
358
+ })();
359
+ priorCleanup.set(target, closing.then(() => ({ kind: "closed" }), (cause) => ({
360
+ kind: "failed",
361
+ error: asError(cause),
362
+ })));
363
+ return closing;
364
+ },
365
+ };
366
+ },
367
+ };
368
+ }
@@ -0,0 +1,23 @@
1
+ import type { HTMLAttributes, ReactNode } from "react";
2
+ import type { AvatarPresentation, PlayerState, SpriteService } from "./player.js";
3
+ /** Shell-owned renderer and asset configuration; no selected identity lives here. */
4
+ export declare function SpriteProvider({ service, children, }: {
5
+ readonly service: SpriteService;
6
+ readonly children: ReactNode;
7
+ }): import("react/jsx-runtime").JSX.Element;
8
+ /** Avatar presentation plus ordinary attributes for the DOM host. */
9
+ export type SpriteProps = AvatarPresentation & Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
10
+ /** Optional for standalone use; a shell normally supplies SpriteProvider. */
11
+ readonly service?: SpriteService;
12
+ readonly paused?: boolean;
13
+ /** Observe loading, ready, error, and disposal changes. */
14
+ readonly onState?: (state: PlayerState) => void;
15
+ };
16
+ /** Own one player; source and appearance changes do not remount its React host. */
17
+ export declare const Sprite: import("react").ForwardRefExoticComponent<AvatarPresentation & Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
18
+ /** Optional for standalone use; a shell normally supplies SpriteProvider. */
19
+ readonly service?: SpriteService;
20
+ readonly paused?: boolean;
21
+ /** Observe loading, ready, error, and disposal changes. */
22
+ readonly onState?: (state: PlayerState) => void;
23
+ } & import("react").RefAttributes<HTMLDivElement>>;
package/dist/react.js ADDED
@@ -0,0 +1,61 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, forwardRef, useContext, useEffect, useImperativeHandle, useRef, } from "react";
3
+ /** Renderer-neutral React presentation. The configured service belongs to the shell. */
4
+ const SpriteContext = createContext(null);
5
+ /** Shell-owned renderer and asset configuration; no selected identity lives here. */
6
+ export function SpriteProvider({ service, children, }) {
7
+ return (_jsx(SpriteContext.Provider, { value: service, children: children }));
8
+ }
9
+ /** Own one player; source and appearance changes do not remount its React host. */
10
+ export const Sprite = forwardRef(function Sprite({ service, src, activity, expression, appearance, renderStyle, paused = false, onState, ...props }, forwardedRef) {
11
+ const configured = useContext(SpriteContext);
12
+ const runtime = service ?? configured;
13
+ if (!runtime)
14
+ throw new Error("Sprite needs a configured SpriteProvider or service");
15
+ const host = useRef(null);
16
+ const player = useRef(null);
17
+ const notify = useRef(onState);
18
+ notify.current = onState;
19
+ useImperativeHandle(forwardedRef, () => {
20
+ if (!host.current)
21
+ throw new Error("Sprite host is not mounted");
22
+ return host.current;
23
+ });
24
+ useEffect(() => {
25
+ if (!host.current)
26
+ return;
27
+ const mounted = runtime.mount(host.current);
28
+ const unsubscribe = mounted.subscribe((state) => {
29
+ if (host.current)
30
+ host.current.dataset.spriteStatus = state.status;
31
+ notify.current?.(state);
32
+ });
33
+ player.current = mounted;
34
+ return () => {
35
+ unsubscribe();
36
+ void mounted.dispose().catch((error) => {
37
+ console.error("Sprite cleanup failed", error);
38
+ });
39
+ player.current = null;
40
+ };
41
+ }, [runtime]);
42
+ useEffect(() => {
43
+ const mounted = player.current;
44
+ if (!mounted)
45
+ return;
46
+ let presentation = { src };
47
+ if (activity !== undefined)
48
+ presentation = { ...presentation, activity };
49
+ if (expression !== undefined)
50
+ presentation = { ...presentation, expression };
51
+ if (appearance !== undefined)
52
+ presentation = { ...presentation, appearance };
53
+ if (renderStyle !== undefined)
54
+ presentation = { ...presentation, renderStyle };
55
+ mounted.set(presentation);
56
+ }, [runtime, src, activity, expression, appearance, renderStyle]);
57
+ useEffect(() => {
58
+ player.current?.pause(paused);
59
+ }, [runtime, paused]);
60
+ return _jsx("div", { ...props, ref: host });
61
+ });
@@ -0,0 +1,45 @@
1
+ import * as z from "zod";
2
+ /**
3
+ * Render styles: an SVG filter drawn over a mounted Sprite. This module owns
4
+ * the style vocabulary, every filter definition, and the DOM layer that mounts
5
+ * one filter per player with ids unique to that player.
6
+ */
7
+ export declare const renderStyles: readonly ["plain", "pencil", "manga", "riso", "halftone", "blueprint", "crt", "watercolor"];
8
+ /** A saved render style; unknown names fail at the boundary. */
9
+ export declare const renderStyleSchema: z.ZodEnum<{
10
+ plain: "plain";
11
+ pencil: "pencil";
12
+ manga: "manga";
13
+ riso: "riso";
14
+ halftone: "halftone";
15
+ blueprint: "blueprint";
16
+ crt: "crt";
17
+ watercolor: "watercolor";
18
+ }>;
19
+ /** How a Sprite is drawn over its native renderer. */
20
+ export type RenderStyle = z.infer<typeof renderStyleSchema>;
21
+ /** Picker labels for each style, in display order. */
22
+ export declare const renderStyleLabels: {
23
+ readonly plain: "Plain";
24
+ readonly pencil: "Pencil";
25
+ readonly manga: "Manga";
26
+ readonly riso: "Riso";
27
+ readonly halftone: "Halftone";
28
+ readonly blueprint: "Blueprint";
29
+ readonly crt: "CRT";
30
+ readonly watercolor: "Watercolor";
31
+ };
32
+ /** The filter layer owned by one mounted player. */
33
+ export type RenderStyleLayer = {
34
+ /** Draw `stage` in `style`; `plain` removes the filter. */
35
+ set(style: RenderStyle, stage: HTMLElement): void;
36
+ /** Hold one still frame: paused, hidden, or reduced motion. */
37
+ still(value: boolean): void;
38
+ /** Stop the boil and remove the filter and its definitions. */
39
+ dispose(): void;
40
+ };
41
+ /**
42
+ * Mount a render-style layer in `host`. The filter applies only to the stage
43
+ * box, and its boil runs only while the host is on screen and not still.
44
+ */
45
+ export declare function createRenderStyleLayer(host: HTMLElement): RenderStyleLayer;