@dopaminefx/core 0.1.0
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/engine/color.d.ts +71 -0
- package/dist/engine/color.d.ts.map +1 -0
- package/dist/engine/color.js +107 -0
- package/dist/engine/color.js.map +1 -0
- package/dist/engine/context.d.ts +54 -0
- package/dist/engine/context.d.ts.map +1 -0
- package/dist/engine/context.js +0 -0
- package/dist/engine/context.js.map +1 -0
- package/dist/engine/gl.d.ts +9 -0
- package/dist/engine/gl.d.ts.map +1 -0
- package/dist/engine/gl.js +39 -0
- package/dist/engine/gl.js.map +1 -0
- package/dist/engine/look/glsl.d.ts +95 -0
- package/dist/engine/look/glsl.d.ts.map +1 -0
- package/dist/engine/look/glsl.js +171 -0
- package/dist/engine/look/glsl.js.map +1 -0
- package/dist/engine/look/particles.glsl.d.ts +21 -0
- package/dist/engine/look/particles.glsl.d.ts.map +1 -0
- package/dist/engine/look/particles.glsl.js +44 -0
- package/dist/engine/look/particles.glsl.js.map +1 -0
- package/dist/engine/sdf.d.ts +77 -0
- package/dist/engine/sdf.d.ts.map +1 -0
- package/dist/engine/sdf.js +255 -0
- package/dist/engine/sdf.js.map +1 -0
- package/dist/engine/seed.d.ts +10 -0
- package/dist/engine/seed.d.ts.map +1 -0
- package/dist/engine/seed.js +20 -0
- package/dist/engine/seed.js.map +1 -0
- package/dist/engine/shadow.d.ts +41 -0
- package/dist/engine/shadow.d.ts.map +1 -0
- package/dist/engine/shadow.js +39 -0
- package/dist/engine/shadow.js.map +1 -0
- package/dist/engine/tempo.d.ts +33 -0
- package/dist/engine/tempo.d.ts.map +1 -0
- package/dist/engine/tempo.js +51 -0
- package/dist/engine/tempo.js.map +1 -0
- package/dist/framework/conductor.d.ts +100 -0
- package/dist/framework/conductor.d.ts.map +1 -0
- package/dist/framework/conductor.js +493 -0
- package/dist/framework/conductor.js.map +1 -0
- package/dist/framework/content.d.ts +67 -0
- package/dist/framework/content.d.ts.map +1 -0
- package/dist/framework/content.js +72 -0
- package/dist/framework/content.js.map +1 -0
- package/dist/framework/dope-pass.d.ts +131 -0
- package/dist/framework/dope-pass.d.ts.map +1 -0
- package/dist/framework/dope-pass.js +346 -0
- package/dist/framework/dope-pass.js.map +1 -0
- package/dist/framework/dope-zip.d.ts +22 -0
- package/dist/framework/dope-zip.d.ts.map +1 -0
- package/dist/framework/dope-zip.js +116 -0
- package/dist/framework/dope-zip.js.map +1 -0
- package/dist/framework/effect.d.ts +128 -0
- package/dist/framework/effect.d.ts.map +1 -0
- package/dist/framework/effect.js +19 -0
- package/dist/framework/effect.js.map +1 -0
- package/dist/framework/frame-expr.d.ts +124 -0
- package/dist/framework/frame-expr.d.ts.map +1 -0
- package/dist/framework/frame-expr.js +135 -0
- package/dist/framework/frame-expr.js.map +1 -0
- package/dist/framework/load-effect.d.ts +77 -0
- package/dist/framework/load-effect.d.ts.map +1 -0
- package/dist/framework/load-effect.js +135 -0
- package/dist/framework/load-effect.js.map +1 -0
- package/dist/framework/loader.d.ts +309 -0
- package/dist/framework/loader.d.ts.map +1 -0
- package/dist/framework/loader.js +266 -0
- package/dist/framework/loader.js.map +1 -0
- package/dist/framework/mood-registry.d.ts +58 -0
- package/dist/framework/mood-registry.d.ts.map +1 -0
- package/dist/framework/mood-registry.js +58 -0
- package/dist/framework/mood-registry.js.map +1 -0
- package/dist/framework/panel-runner.d.ts +96 -0
- package/dist/framework/panel-runner.d.ts.map +1 -0
- package/dist/framework/panel-runner.js +137 -0
- package/dist/framework/panel-runner.js.map +1 -0
- package/dist/framework/pass-common.d.ts +97 -0
- package/dist/framework/pass-common.d.ts.map +1 -0
- package/dist/framework/pass-common.js +178 -0
- package/dist/framework/pass-common.js.map +1 -0
- package/dist/framework/pass-runner.d.ts +183 -0
- package/dist/framework/pass-runner.d.ts.map +1 -0
- package/dist/framework/pass-runner.js +212 -0
- package/dist/framework/pass-runner.js.map +1 -0
- package/dist/framework/programs.d.ts +54 -0
- package/dist/framework/programs.d.ts.map +1 -0
- package/dist/framework/programs.js +33 -0
- package/dist/framework/programs.js.map +1 -0
- package/dist/framework/registry.d.ts +29 -0
- package/dist/framework/registry.d.ts.map +1 -0
- package/dist/framework/registry.js +38 -0
- package/dist/framework/registry.js.map +1 -0
- package/dist/framework/runtime.d.ts +19 -0
- package/dist/framework/runtime.d.ts.map +1 -0
- package/dist/framework/runtime.js +37 -0
- package/dist/framework/runtime.js.map +1 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +126 -0
- package/dist/index.js.map +1 -0
- package/dist/overlay.d.ts +46 -0
- package/dist/overlay.d.ts.map +1 -0
- package/dist/overlay.js +79 -0
- package/dist/overlay.js.map +1 -0
- package/dist/types.d.ts +68 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -0
- package/package.json +37 -0
- package/src/engine/color.ts +154 -0
- package/src/engine/context.ts +0 -0
- package/src/engine/gl.ts +46 -0
- package/src/engine/look/glsl.ts +183 -0
- package/src/engine/look/particles.glsl.ts +44 -0
- package/src/engine/sdf.ts +298 -0
- package/src/engine/seed.ts +23 -0
- package/src/engine/shadow.ts +66 -0
- package/src/engine/tempo.ts +54 -0
- package/src/framework/conductor.ts +604 -0
- package/src/framework/content.ts +113 -0
- package/src/framework/dope-pass.ts +432 -0
- package/src/framework/dope-zip.ts +125 -0
- package/src/framework/effect.ts +127 -0
- package/src/framework/frame-expr.ts +217 -0
- package/src/framework/load-effect.ts +204 -0
- package/src/framework/loader.ts +502 -0
- package/src/framework/mood-registry.ts +87 -0
- package/src/framework/panel-runner.ts +233 -0
- package/src/framework/pass-common.ts +222 -0
- package/src/framework/pass-runner.ts +391 -0
- package/src/framework/programs.ts +62 -0
- package/src/framework/registry.ts +44 -0
- package/src/framework/runtime.ts +38 -0
- package/src/index.ts +227 -0
- package/src/overlay.ts +109 -0
- package/src/types.ts +63 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.dope` CONTENT + TYPOGRAPHY consumers (Phase 3).
|
|
3
|
+
*
|
|
4
|
+
* Phase 0–2 moved an effect's numeric/palette/tempo params + its icon geometry
|
|
5
|
+
* into the `.dope`. Phase 3 finishes the job for the last code-shaped data:
|
|
6
|
+
*
|
|
7
|
+
* - `content.words` / `content.checkToken` — Comic's affirmation pool + the
|
|
8
|
+
* checkmark sentinel, picked per-fire by seed.
|
|
9
|
+
* - `content.glyphBands` — Solarbloom's whimsy→check-glyph (face + char) bands.
|
|
10
|
+
* - `typography` — Comic's mood→face baselines + the whimsy/intensity CURVE
|
|
11
|
+
* table (skew/stretch/tracking/outlineLayers/extrude/jitter/roundness),
|
|
12
|
+
* evaluated with the mapping grammar (extended with mix/max/min).
|
|
13
|
+
*
|
|
14
|
+
* These resolvers reproduce the legacy `mood.ts` arithmetic EXACTLY (the legacy
|
|
15
|
+
* functions stay as the parity reference, just like the numeric path), so a
|
|
16
|
+
* built-in's output is byte-identical while reskinning (different words, font,
|
|
17
|
+
* curves) becomes pure `.dope` editing.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { evalExpr, type EvalCtx, type ExprNode } from "./loader.js";
|
|
21
|
+
import { mulberry32 } from "../engine/seed.js";
|
|
22
|
+
|
|
23
|
+
const clamp01 = (x: number): number => (x < 0 ? 0 : x > 1 ? 1 : x);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Deterministically pick one of `list` from a seed. Matches Comic's `pickWord`:
|
|
27
|
+
* `mulberry32(seed>>>0)()` → index. Same seed → same pick; un-pinned scatters.
|
|
28
|
+
*/
|
|
29
|
+
export function pickFromList<T>(list: readonly T[], seed: number): T {
|
|
30
|
+
const r = mulberry32(seed >>> 0)();
|
|
31
|
+
const idx = Math.min(list.length - 1, Math.floor(r * list.length));
|
|
32
|
+
return list[idx]!;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Pick a band by whimsy (0..1), splitting the slider into equal bands. Matches
|
|
37
|
+
* Solarbloom's `pickCheckGlyph`: `floor(w * n)` clamped to the last band.
|
|
38
|
+
*/
|
|
39
|
+
export function pickBand<T>(bands: readonly T[], whimsy: number): T {
|
|
40
|
+
const w = clamp01(whimsy);
|
|
41
|
+
const idx = Math.min(bands.length - 1, Math.floor(w * bands.length));
|
|
42
|
+
return bands[idx]!;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// TYPOGRAPHY — the declarative table that replaces `comicTypography`.
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/** Per-mood typographic baselines (the `ComicBaseline` typographic fields). */
|
|
50
|
+
export interface TypographyMoodBaseline {
|
|
51
|
+
face: string;
|
|
52
|
+
skew: number;
|
|
53
|
+
tilt: number;
|
|
54
|
+
stretchX: number;
|
|
55
|
+
tracking: number;
|
|
56
|
+
roundness: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The `typography` section of a `.dope`. */
|
|
60
|
+
export interface DopeTypography {
|
|
61
|
+
/** Robust CSS fallback chain appended after the mood's primary face. */
|
|
62
|
+
fallbackStack: string;
|
|
63
|
+
/** Per-mood baselines, keyed by mood name. */
|
|
64
|
+
perMood: Record<string, TypographyMoodBaseline>;
|
|
65
|
+
/**
|
|
66
|
+
* Derived numeric fields, each an expression over `control` (intensity/whimsy)
|
|
67
|
+
* + `baseline` (the per-mood typographic baseline). String fields (fontStack)
|
|
68
|
+
* are assembled separately. clamp01/round flags mirror the param specs.
|
|
69
|
+
*/
|
|
70
|
+
fields: Record<string, { from: ExprNode; clamp01?: boolean; round?: boolean }>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Resolved typography (the numeric fields + the assembled font stack). */
|
|
74
|
+
export interface ResolvedTypography {
|
|
75
|
+
fontStack: string;
|
|
76
|
+
[field: string]: number | string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Evaluate a typography table for a mood + intensity + whimsy. Pure; the
|
|
81
|
+
* `baseline` context is the per-mood typographic baseline so a field expr can
|
|
82
|
+
* reference e.g. `{ "baseline": "stretchX" }` or `{ "baseline": "roundness" }`.
|
|
83
|
+
*/
|
|
84
|
+
export function resolveTypography(
|
|
85
|
+
typo: DopeTypography,
|
|
86
|
+
mood: string,
|
|
87
|
+
intensity: number,
|
|
88
|
+
whimsy: number,
|
|
89
|
+
): ResolvedTypography {
|
|
90
|
+
// Degrade an undeclared mood to the first declared typography mood (not a
|
|
91
|
+
// hardcoded "celebratory"), matching the loader's own-default fallback.
|
|
92
|
+
const base = typo.perMood[mood] ?? typo.perMood[Object.keys(typo.perMood)[0]!]!;
|
|
93
|
+
const ctx: EvalCtx = {
|
|
94
|
+
controls: { intensity: clamp01(intensity), whimsy: clamp01(whimsy) },
|
|
95
|
+
// Only the numeric baselines are visible to the grammar.
|
|
96
|
+
baseline: {
|
|
97
|
+
skew: base.skew,
|
|
98
|
+
tilt: base.tilt,
|
|
99
|
+
stretchX: base.stretchX,
|
|
100
|
+
tracking: base.tracking,
|
|
101
|
+
roundness: base.roundness,
|
|
102
|
+
},
|
|
103
|
+
consts: {},
|
|
104
|
+
};
|
|
105
|
+
const out: ResolvedTypography = { fontStack: `${base.face}, ${typo.fallbackStack}` };
|
|
106
|
+
for (const [name, spec] of Object.entries(typo.fields)) {
|
|
107
|
+
let v = evalExpr(spec.from, ctx);
|
|
108
|
+
if (spec.round) v = Math.round(v);
|
|
109
|
+
if (spec.clamp01) v = clamp01(v);
|
|
110
|
+
out[name] = v;
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic DATA-DRIVEN pass factory — instantiates a pure-shader effect from
|
|
3
|
+
* `(dope, shader, hooks)` with no per-effect factory code.
|
|
4
|
+
*
|
|
5
|
+
* For a datafied effect the `.dope` carries everything the hand-written
|
|
6
|
+
* per-effect `PassConfig` used to: the per-frame logic (`tempo.frame`), the
|
|
7
|
+
* shadow height (`render.shadowHeightFrac`), the per-pass uniforms
|
|
8
|
+
* (`render.pass`), the SDF-sourced samplers (`binding.samplers[].outline`),
|
|
9
|
+
* the loop-cap consts (`render.consts`), the runner config
|
|
10
|
+
* (`render.config.usesOrigin`), the reduced-motion peak/hold
|
|
11
|
+
* (`tempo.reducedMotion`) and the uniform-binding contract (`binding`). This
|
|
12
|
+
* module derives the `PassConfig` from that data — uniform names by the same
|
|
13
|
+
* `name → u<Name>` convention `computeScalarBinds` applies, exceptions from
|
|
14
|
+
* the binding contract — so the only hand-written web source left for such an
|
|
15
|
+
* effect is its GLSL.
|
|
16
|
+
*
|
|
17
|
+
* The honest boundary stays honest: anything genuinely code-shaped (a sprite
|
|
18
|
+
* panel, frame arrays, a host-rasterized aux texture) is passed through
|
|
19
|
+
* `hooks`, the same seams `PassConfig` always had — and a hook overrides the
|
|
20
|
+
* derived `auxTextures`/`passUniforms` when both exist.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { decodeSdf } from "../engine/sdf.js";
|
|
24
|
+
import { evalFrameExpr, evalParamExpr, evalPassExpr, type FrameExprNode } from "./frame-expr.js";
|
|
25
|
+
import { getOutline, resolveDopeParams, type DopeDoc, type DopeSampler } from "./loader.js";
|
|
26
|
+
import { cap } from "./pass-common.js";
|
|
27
|
+
import {
|
|
28
|
+
createPassInstance,
|
|
29
|
+
type AuxTextureSpec,
|
|
30
|
+
type FrameInfo,
|
|
31
|
+
type PassConfig,
|
|
32
|
+
type PassParams,
|
|
33
|
+
} from "./pass-runner.js";
|
|
34
|
+
import { createPanelInstance, type PanelConfig, type PanelDraw } from "./panel-runner.js";
|
|
35
|
+
import { registerEffect } from "./registry.js";
|
|
36
|
+
import { registerProgram, type RenderProgram } from "./programs.js";
|
|
37
|
+
import type { EffectContext, EffectFactory, EffectInstance, FeelingInput } from "./effect.js";
|
|
38
|
+
|
|
39
|
+
/** The code-shaped escape hatches a datafied effect may still need. */
|
|
40
|
+
export type DopePassHooks = Partial<
|
|
41
|
+
Pick<PassConfig, "auxTextures" | "passUniforms" | "panel" | "frameArrays">
|
|
42
|
+
> & {
|
|
43
|
+
/** Additional uniform names (beyond the derived set) the shader reads. */
|
|
44
|
+
extraUniforms?: readonly string[];
|
|
45
|
+
/**
|
|
46
|
+
* The Canvas2D draw for a PASS effect's dynamic sprite panel (`render.panel`
|
|
47
|
+
* with the doc still pass-shaped — e.g. solarbloom's motes). The `.dope`
|
|
48
|
+
* `render.panel` block supplies the unit + sampler; this hook supplies only
|
|
49
|
+
* the genuinely code-shaped draw. Ignored when `hooks.panel` is given whole.
|
|
50
|
+
*/
|
|
51
|
+
panelDraw?: NonNullable<PassConfig["panel"]>["draw"];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** The vertex + fragment GLSL pair (the per-effect look — code by design). */
|
|
55
|
+
export interface DopeShader {
|
|
56
|
+
vertex: string;
|
|
57
|
+
fragment: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The shared pass/panel derivation over a datafied `.dope`: the uniform list,
|
|
62
|
+
* the binding exceptions, the per-frame and per-pass expression tables and the
|
|
63
|
+
* declared SDF pass inputs — everything both `dopePassConfig` and
|
|
64
|
+
* `dopePanelConfig` read identically (the rules in the {@link dopePassConfig}
|
|
65
|
+
* doc comment).
|
|
66
|
+
*/
|
|
67
|
+
function deriveDope(doc: DopeDoc, extraUniforms?: readonly string[]) {
|
|
68
|
+
const binding = doc.binding ?? {};
|
|
69
|
+
const exclude = binding.excludeParams ?? [];
|
|
70
|
+
const scatterKey = binding.scatterKey ?? undefined;
|
|
71
|
+
const extraDefs = binding.extras ?? [];
|
|
72
|
+
|
|
73
|
+
const frameSpec = doc.tempo.frame;
|
|
74
|
+
if (!frameSpec) throw new Error(`dope: ${doc.id} has no tempo.frame (not a datafied effect)`);
|
|
75
|
+
const loopPeriodMs = doc.tempo.loop?.periodMs;
|
|
76
|
+
const shadowSpec = doc.render.shadowHeightFrac;
|
|
77
|
+
if (shadowSpec === undefined) {
|
|
78
|
+
throw new Error(`dope: ${doc.id} has no render.shadowHeightFrac (not a datafied effect)`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- uniforms ------------------------------------------------------------
|
|
82
|
+
const uniforms = new Set<string>();
|
|
83
|
+
for (const name of Object.keys(doc.render.params)) {
|
|
84
|
+
if (exclude.includes(name) || name === scatterKey) continue;
|
|
85
|
+
uniforms.add(cap(name));
|
|
86
|
+
}
|
|
87
|
+
if (scatterKey && binding.scatterWeb) uniforms.add(binding.scatterWeb);
|
|
88
|
+
for (const e of extraDefs) if (e.web) uniforms.add(e.web);
|
|
89
|
+
for (const s of binding.samplers ?? []) uniforms.add(typeof s === "string" ? s : s.web);
|
|
90
|
+
for (const a of binding.arrays ?? []) uniforms.add(a.web);
|
|
91
|
+
for (const u of extraUniforms ?? []) uniforms.add(u);
|
|
92
|
+
|
|
93
|
+
// --- bindings (exceptions to the `name → u<Name>` auto-bind) --------------
|
|
94
|
+
const bindings: Record<string, string | null> = {};
|
|
95
|
+
if (scatterKey) bindings[scatterKey] = binding.scatterWeb ?? null;
|
|
96
|
+
for (const name of exclude) {
|
|
97
|
+
if (name === "style" || name === "durationMs") continue; // see doc comment
|
|
98
|
+
bindings[name] = null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- per-frame extras: canonical name → web uniform name ------------------
|
|
102
|
+
const extraExprs: Array<[string, FrameExprNode]> = Object.entries(frameSpec.extras ?? {}).map(
|
|
103
|
+
([name, expr]) => {
|
|
104
|
+
const def = extraDefs.find((e) => e.name === name);
|
|
105
|
+
if (!def?.web) {
|
|
106
|
+
throw new Error(`dope: ${doc.id} tempo.frame.extras."${name}" has no binding.extras web name`);
|
|
107
|
+
}
|
|
108
|
+
return [def.web, expr];
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// --- per-PASS uniforms (`render.pass`): canonical name → web uniform name --
|
|
113
|
+
// (a "note" key is documentation, not an expression — same convention as
|
|
114
|
+
// `binding.note`.)
|
|
115
|
+
const passExprs: Array<[string, FrameExprNode]> = Object.entries(doc.render.pass ?? {})
|
|
116
|
+
.filter(([name]) => name !== "note")
|
|
117
|
+
.map(([name, expr]) => {
|
|
118
|
+
const def = extraDefs.find((e) => e.name === name);
|
|
119
|
+
if (!def?.web) {
|
|
120
|
+
throw new Error(`dope: ${doc.id} render.pass."${name}" has no binding.extras web name`);
|
|
121
|
+
}
|
|
122
|
+
return [def.web, expr] as [string, FrameExprNode];
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// SDF-sourced samplers: the first `outline` sampler supplies the pass-expr
|
|
126
|
+
// SDF inputs (declared metadata — readable even where the bitmap isn't bound).
|
|
127
|
+
const samplerDefs = (binding.samplers ?? []).filter((s): s is DopeSampler => typeof s !== "string");
|
|
128
|
+
const sdfOutline = samplerDefs.find((s) => s.outline);
|
|
129
|
+
const sdfSrc = sdfOutline ? getOutline(doc, sdfOutline.outline!)?.sdf : undefined;
|
|
130
|
+
const sdfInputs = { range: sdfSrc?.range ?? 0, viewBoxW: sdfSrc?.viewBox?.[2] ?? 0 };
|
|
131
|
+
|
|
132
|
+
return { binding, frameSpec, loopPeriodMs, shadowSpec, uniforms, bindings, extraExprs, passExprs, sdfInputs };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Derive a {@link PassConfig} from a datafied `.dope` + its shader (+ optional
|
|
137
|
+
* code hooks). The derived contract is pinned by the per-effect dope-config
|
|
138
|
+
* vitests:
|
|
139
|
+
*
|
|
140
|
+
* - `uniforms`: every `render.params` key not in `binding.excludeParams` and
|
|
141
|
+
* not the scatter key → `u<Name>`; the scatter key contributes
|
|
142
|
+
* `binding.scatterWeb` when present (else it is not a shader uniform); every
|
|
143
|
+
* `binding.extras[].web`; every `binding.samplers[].web`; every
|
|
144
|
+
* `binding.arrays[].web` (the `frameArrays` uniform arrays); plus
|
|
145
|
+
* `hooks.extraUniforms`.
|
|
146
|
+
* - `bindings`: the scatter key → `scatterWeb` (or `null`), plus `null` for
|
|
147
|
+
* each excluded param that would otherwise auto-bind. (`style` and
|
|
148
|
+
* `durationMs` need no entry: `durationMs` is skipped by
|
|
149
|
+
* `computeScalarBinds`, and `style`'s conventional `uStyle` auto-bind is the
|
|
150
|
+
* same value the runner already sets as a standard uniform.)
|
|
151
|
+
* - `frame`: `tempo.frame.amp` + `tempo.frame.extras` evaluated per frame
|
|
152
|
+
* (extras keyed by canonical name, emitted under their `binding` web name).
|
|
153
|
+
* - `shadowHeightFrac`: `render.shadowHeightFrac` (bare number passes
|
|
154
|
+
* through; an expression is params-only — `{input}` throws).
|
|
155
|
+
* - `passUniforms`: `render.pass` evaluated ONCE PER PASS (canonical names →
|
|
156
|
+
* `binding` web names) over the resolved params + the pass-geometry inputs
|
|
157
|
+
* (`targetMinDimPx`, plus `sdfRange`/`sdfViewBoxW` from the first sampler
|
|
158
|
+
* with an `outline` source).
|
|
159
|
+
* - `auxTextures`: every `binding.samplers` entry with an `outline` whose
|
|
160
|
+
* baked SDF decodes → a `kind:"sdf"` spec at its `texture` unit, flipping
|
|
161
|
+
* its `on` extra to 1; absent/undecodable → analytic fallback.
|
|
162
|
+
* - `usesOrigin`: `render.config.usesOrigin`.
|
|
163
|
+
*/
|
|
164
|
+
export function dopePassConfig(doc: DopeDoc, shader: DopeShader, hooks: DopePassHooks = {}): PassConfig {
|
|
165
|
+
const { binding, frameSpec, loopPeriodMs, shadowSpec, uniforms, bindings, extraExprs, passExprs, sdfInputs } =
|
|
166
|
+
deriveDope(doc, hooks.extraUniforms);
|
|
167
|
+
|
|
168
|
+
const derivedPassUniforms: PassConfig["passUniforms"] | undefined = passExprs.length
|
|
169
|
+
? (_canvas, params, targetPx, dpr) => {
|
|
170
|
+
const pass = {
|
|
171
|
+
targetMinDimPx: Math.min(targetPx.width, targetPx.height),
|
|
172
|
+
sdfRange: sdfInputs.range,
|
|
173
|
+
sdfViewBoxW: sdfInputs.viewBoxW,
|
|
174
|
+
dpr,
|
|
175
|
+
};
|
|
176
|
+
const out: Record<string, number> = {};
|
|
177
|
+
for (const [web, expr] of passExprs) out[web] = evalPassExpr(expr, params, pass);
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
: undefined;
|
|
181
|
+
|
|
182
|
+
// The declarative dynamic-panel WIRING (`render.panel`): the unit + sampler
|
|
183
|
+
// are data; the Canvas2D draw is the code-shaped hook. (A whole `hooks.panel`
|
|
184
|
+
// still overrides, like the other hooks.)
|
|
185
|
+
const panelSpec = doc.render.panel;
|
|
186
|
+
const derivedPanel: PassConfig["panel"] | undefined =
|
|
187
|
+
panelSpec && hooks.panelDraw
|
|
188
|
+
? { unit: panelSpec.texture ?? 0, sampler: panelSpec.sampler, draw: hooks.panelDraw }
|
|
189
|
+
: undefined;
|
|
190
|
+
|
|
191
|
+
// Declarative aux textures: each sampler with an `outline` whose baked SDF
|
|
192
|
+
// decodes becomes a kind:"sdf" spec (its `on` extra flips to 1 when bound);
|
|
193
|
+
// an absent/undecodable SDF contributes nothing — the analytic fallback.
|
|
194
|
+
const extraDefs = binding.extras ?? [];
|
|
195
|
+
const samplerDefs = (binding.samplers ?? []).filter((s): s is DopeSampler => typeof s !== "string");
|
|
196
|
+
const sdfAux: AuxTextureSpec[] = [];
|
|
197
|
+
for (const s of samplerDefs) {
|
|
198
|
+
if (!s.outline) continue;
|
|
199
|
+
const baked = getOutline(doc, s.outline)?.sdf;
|
|
200
|
+
if (!baked) continue;
|
|
201
|
+
try {
|
|
202
|
+
const onDef = s.on ? extraDefs.find((e) => e.name === s.on) : undefined;
|
|
203
|
+
sdfAux.push({
|
|
204
|
+
kind: "sdf",
|
|
205
|
+
unit: s.texture ?? 0,
|
|
206
|
+
sdf: decodeSdf(baked),
|
|
207
|
+
sampler: s.web,
|
|
208
|
+
onUniform: onDef?.web,
|
|
209
|
+
});
|
|
210
|
+
} catch {
|
|
211
|
+
/* undecodable → analytic fallback */
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const derivedAux: PassConfig["auxTextures"] | undefined = sdfAux.length ? () => sdfAux : undefined;
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
vertex: shader.vertex,
|
|
218
|
+
fragment: shader.fragment,
|
|
219
|
+
uniforms: [...uniforms],
|
|
220
|
+
usesOrigin: doc.render.config?.usesOrigin ?? false,
|
|
221
|
+
loopPeriodMs,
|
|
222
|
+
bindings,
|
|
223
|
+
shadowHeightFrac:
|
|
224
|
+
typeof shadowSpec === "number" ? shadowSpec : (params) => evalParamExpr(shadowSpec, params),
|
|
225
|
+
frame(info: FrameInfo, params: PassParams) {
|
|
226
|
+
// Loop clocks (0 without tempo.loop) — the SAME formula the runner uses
|
|
227
|
+
// for uLoopS/uPhase, so a `{input:"phase"}` amp matches the shader.
|
|
228
|
+
const loopMs = loopPeriodMs ? info.animMs % loopPeriodMs : 0;
|
|
229
|
+
const ctx = {
|
|
230
|
+
animMs: info.animMs,
|
|
231
|
+
life: info.life,
|
|
232
|
+
elapsedMs: info.elapsedMs,
|
|
233
|
+
params,
|
|
234
|
+
loopS: loopMs / 1000,
|
|
235
|
+
phase: loopPeriodMs ? loopMs / loopPeriodMs : 0,
|
|
236
|
+
};
|
|
237
|
+
const out: { amp: number } & Record<string, number> = {
|
|
238
|
+
amp: evalFrameExpr(frameSpec.amp, ctx),
|
|
239
|
+
};
|
|
240
|
+
for (const [web, expr] of extraExprs) out[web] = evalFrameExpr(expr, ctx);
|
|
241
|
+
return out;
|
|
242
|
+
},
|
|
243
|
+
auxTextures: hooks.auxTextures ?? derivedAux,
|
|
244
|
+
passUniforms: hooks.passUniforms ?? derivedPassUniforms,
|
|
245
|
+
panel: hooks.panel ?? derivedPanel,
|
|
246
|
+
frameArrays: hooks.frameArrays,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Derive a {@link PanelConfig} (the Canvas2D-panel runner's config) from a
|
|
252
|
+
* datafied PANEL `.dope` + its shader + the one genuinely code-shaped piece —
|
|
253
|
+
* the per-frame Canvas2D `draw`. The derivation rules are the SAME as
|
|
254
|
+
* {@link dopePassConfig} (uniforms / bindings / `tempo.frame` /
|
|
255
|
+
* `render.shadowHeightFrac` / `render.pass`), with the panel runner's shape:
|
|
256
|
+
*
|
|
257
|
+
* - `panelSampler`: `render.panel.sampler` (required; `render.panel.texture`
|
|
258
|
+
* must be 0 — the panel runner binds the panel at TEXTURE0, the
|
|
259
|
+
* cross-platform panel slot).
|
|
260
|
+
* - `frame`: evaluated with `animMs := elapsedMs` — the panel runner never
|
|
261
|
+
* snaps "on twos" (declared as `render.config.stepping: "none"`).
|
|
262
|
+
* - `passUniforms`: `render.pass` over the resolved params + the pass inputs
|
|
263
|
+
* (`targetMinDimPx` from the targeted element box, `dpr`, and the SDF
|
|
264
|
+
* metadata when a sampler declares an outline).
|
|
265
|
+
*/
|
|
266
|
+
export function dopePanelConfig(
|
|
267
|
+
doc: DopeDoc,
|
|
268
|
+
shader: DopeShader,
|
|
269
|
+
draw: PanelDraw,
|
|
270
|
+
hooks: Pick<DopePassHooks, "extraUniforms"> = {},
|
|
271
|
+
): PanelConfig {
|
|
272
|
+
const { frameSpec, shadowSpec, uniforms, bindings, extraExprs, passExprs, sdfInputs } = deriveDope(
|
|
273
|
+
doc,
|
|
274
|
+
hooks.extraUniforms,
|
|
275
|
+
);
|
|
276
|
+
const panelSpec = doc.render.panel;
|
|
277
|
+
if (!panelSpec) throw new Error(`dope: ${doc.id} has no render.panel (not a panel effect)`);
|
|
278
|
+
if ((panelSpec.texture ?? 0) !== 0) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`dope: ${doc.id} render.panel.texture must be 0 for a panel-kind effect (TEXTURE0 is the panel slot)`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
if (doc.tempo.loop) throw new Error(`dope: ${doc.id} tempo.loop is not supported by the panel runner`);
|
|
284
|
+
|
|
285
|
+
const derivedPassUniforms: PanelConfig["passUniforms"] | undefined = passExprs.length
|
|
286
|
+
? (_canvas, params, dpr, targetPx) => {
|
|
287
|
+
const pass = {
|
|
288
|
+
targetMinDimPx: Math.min(targetPx.width, targetPx.height),
|
|
289
|
+
sdfRange: sdfInputs.range,
|
|
290
|
+
sdfViewBoxW: sdfInputs.viewBoxW,
|
|
291
|
+
dpr,
|
|
292
|
+
};
|
|
293
|
+
const out: Record<string, number> = {};
|
|
294
|
+
for (const [web, expr] of passExprs) out[web] = evalPassExpr(expr, params, pass);
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
: undefined;
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
vertex: shader.vertex,
|
|
301
|
+
fragment: shader.fragment,
|
|
302
|
+
uniforms: [...uniforms],
|
|
303
|
+
panelSampler: panelSpec.sampler,
|
|
304
|
+
bindings,
|
|
305
|
+
shadowHeightFrac:
|
|
306
|
+
typeof shadowSpec === "number" ? shadowSpec : (params) => evalParamExpr(shadowSpec, params),
|
|
307
|
+
draw,
|
|
308
|
+
frame(info, params) {
|
|
309
|
+
// Panels never snap on twos (`render.config.stepping: "none"`), so the
|
|
310
|
+
// snapped clock IS the wall clock — `animMs := elapsedMs`.
|
|
311
|
+
const ctx = {
|
|
312
|
+
animMs: info.elapsedMs,
|
|
313
|
+
life: info.life,
|
|
314
|
+
elapsedMs: info.elapsedMs,
|
|
315
|
+
params,
|
|
316
|
+
loopS: 0,
|
|
317
|
+
phase: 0,
|
|
318
|
+
};
|
|
319
|
+
const out: { amp: number } & Record<string, number> = {
|
|
320
|
+
amp: evalFrameExpr(frameSpec.amp, ctx),
|
|
321
|
+
};
|
|
322
|
+
for (const [web, expr] of extraExprs) out[web] = evalFrameExpr(expr, ctx);
|
|
323
|
+
return out;
|
|
324
|
+
},
|
|
325
|
+
passUniforms: derivedPassUniforms,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Options for {@link registerDopeEffect}. */
|
|
330
|
+
export interface RegisterDopeEffectOptions {
|
|
331
|
+
/** Code-shaped escape hatches forwarded to {@link dopePassConfig}. */
|
|
332
|
+
hooks?: DopePassHooks;
|
|
333
|
+
/**
|
|
334
|
+
* The registered effect/program name. Defaults to the last segment of
|
|
335
|
+
* `doc.id` (e.g. `dopamine.success.aurora` → `aurora`); pass explicitly when
|
|
336
|
+
* the public name differs (e.g. `dopamine.success.verdict` → `inkstroke`).
|
|
337
|
+
*/
|
|
338
|
+
name?: string;
|
|
339
|
+
/** Also register as a bundled program for `loadEffect()`. Default true. */
|
|
340
|
+
program?: boolean;
|
|
341
|
+
/**
|
|
342
|
+
* Compose non-numeric, code-shaped params on top of the loader bag — applied
|
|
343
|
+
* to the factory's `resolve` AND forwarded to the bundled program.
|
|
344
|
+
*/
|
|
345
|
+
composeParams?: RenderProgram["composeParams"];
|
|
346
|
+
/** Override `tempo.reducedMotion`. */
|
|
347
|
+
reducedMotion?: { peakMs?: number; holdMs?: number };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Build + register a fully data-driven effect from `(dope, shader, hooks)`:
|
|
352
|
+
* `resolve` is the `.dope` loader (with `render.consts` and the binding's
|
|
353
|
+
* scatter key), `create` is the generic pass runner over the derived config,
|
|
354
|
+
* and `reducedMotion` comes from `tempo.reducedMotion`. Registers the
|
|
355
|
+
* `EffectFactory` (and, by default, a bundled program under the same name) and
|
|
356
|
+
* returns the factory — the whole per-effect web factory, datafied.
|
|
357
|
+
*/
|
|
358
|
+
export function registerDopeEffect(
|
|
359
|
+
doc: DopeDoc,
|
|
360
|
+
shader: DopeShader,
|
|
361
|
+
opts: RegisterDopeEffectOptions = {},
|
|
362
|
+
): EffectFactory<PassParams> {
|
|
363
|
+
const config = dopePassConfig(doc, shader, opts.hooks);
|
|
364
|
+
const create = (params: PassParams, ctx: EffectContext): EffectInstance =>
|
|
365
|
+
createPassInstance(config, params, ctx);
|
|
366
|
+
return registerDerived(doc, create, opts);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Build + register a data-driven Canvas2D-PANEL effect from
|
|
371
|
+
* `(dope, shader, draw)`: the same registration as {@link registerDopeEffect}
|
|
372
|
+
* but `create` is the generic PANEL runner over the {@link dopePanelConfig}
|
|
373
|
+
* derivation. The only hand-written web sources left for such an effect are
|
|
374
|
+
* its GLSL and the Canvas2D `draw` — the per-platform panel-draw seam.
|
|
375
|
+
*/
|
|
376
|
+
export function registerDopePanelEffect(
|
|
377
|
+
doc: DopeDoc,
|
|
378
|
+
shader: DopeShader,
|
|
379
|
+
draw: PanelDraw,
|
|
380
|
+
opts: RegisterDopeEffectOptions = {},
|
|
381
|
+
): EffectFactory<PassParams> {
|
|
382
|
+
const config = dopePanelConfig(doc, shader, draw, { extraUniforms: opts.hooks?.extraUniforms });
|
|
383
|
+
const create = (params: PassParams, ctx: EffectContext): EffectInstance =>
|
|
384
|
+
createPanelInstance(config, params, ctx);
|
|
385
|
+
return registerDerived(doc, create, opts);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The shared registration tail: resolve/reducedMotion/loop + the bundled program. */
|
|
389
|
+
function registerDerived(
|
|
390
|
+
doc: DopeDoc,
|
|
391
|
+
create: (params: PassParams, ctx: EffectContext) => EffectInstance,
|
|
392
|
+
opts: RegisterDopeEffectOptions,
|
|
393
|
+
): EffectFactory<PassParams> {
|
|
394
|
+
const scatterKey = doc.binding?.scatterKey;
|
|
395
|
+
if (!scatterKey) throw new Error(`dope: ${doc.id} has no binding.scatterKey`);
|
|
396
|
+
const consts = doc.render.consts ?? {};
|
|
397
|
+
const name = opts.name ?? doc.id.split(".").pop()!;
|
|
398
|
+
const reducedMotion = opts.reducedMotion ?? doc.tempo.reducedMotion;
|
|
399
|
+
|
|
400
|
+
const factory: EffectFactory<PassParams> = {
|
|
401
|
+
name,
|
|
402
|
+
resolve: (feeling: FeelingInput) => {
|
|
403
|
+
const numeric = resolveDopeParams(doc, feeling, consts, scatterKey);
|
|
404
|
+
return (
|
|
405
|
+
opts.composeParams
|
|
406
|
+
? opts.composeParams(
|
|
407
|
+
numeric as Record<string, unknown>,
|
|
408
|
+
feeling as { mood: string; intensity: number; whimsy: number; seed: number },
|
|
409
|
+
)
|
|
410
|
+
: numeric
|
|
411
|
+
) as unknown as PassParams;
|
|
412
|
+
},
|
|
413
|
+
create,
|
|
414
|
+
reducedMotion,
|
|
415
|
+
// The continuous-loop contract: the conductor re-arms at durationMs instead
|
|
416
|
+
// of tearing down (the host stops it via the returned handle).
|
|
417
|
+
loop: doc.tempo.loop ? { periodMs: doc.tempo.loop.periodMs } : undefined,
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
if (opts.program !== false) {
|
|
421
|
+
// Expose as a bundled program so loadEffect() can bind host-authored
|
|
422
|
+
// variants of this effect's .dope with no code.
|
|
423
|
+
registerProgram<PassParams>(name, {
|
|
424
|
+
create,
|
|
425
|
+
scatterKey,
|
|
426
|
+
consts,
|
|
427
|
+
reducedMotion,
|
|
428
|
+
...(opts.composeParams ? { composeParams: opts.composeParams } : {}),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
return registerEffect(factory);
|
|
432
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal `.dope` zip (dotLottie-style) reader.
|
|
3
|
+
*
|
|
4
|
+
* A distributed `.dope` may be a zip: a `manifest.json` naming the effect doc
|
|
5
|
+
* plus an `assets/` dir referenced by RELATIVE paths only. This reads the zip
|
|
6
|
+
* entries (STORED or DEFLATE), finds the effect JSON via the manifest (or the
|
|
7
|
+
* first `*.json` under `effects/`), and resolves any relative asset `$ref`s in
|
|
8
|
+
* `geometry.outlines.*` by inlining them as `data:` URIs — so the doc handed to
|
|
9
|
+
* the loader is fully self-contained. Remote/absolute refs are rejected (the
|
|
10
|
+
* loader's standalone guard would reject them anyway; we fail early + clearly).
|
|
11
|
+
*
|
|
12
|
+
* Dependency-free: STORED entries are read directly; DEFLATE entries use the
|
|
13
|
+
* platform `DecompressionStream` when present (browsers + Node ≥18). We don't
|
|
14
|
+
* bundle a zlib.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
interface ZipEntry {
|
|
18
|
+
name: string;
|
|
19
|
+
method: number;
|
|
20
|
+
data: Uint8Array; // raw (possibly compressed) bytes
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function u16(b: Uint8Array, o: number): number {
|
|
24
|
+
return b[o]! | (b[o + 1]! << 8);
|
|
25
|
+
}
|
|
26
|
+
function u32(b: Uint8Array, o: number): number {
|
|
27
|
+
return (b[o]! | (b[o + 1]! << 8) | (b[o + 2]! << 16) | (b[o + 3]! << 24)) >>> 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Parse the local-file-header records of a zip into raw entries. */
|
|
31
|
+
function parseZip(buf: Uint8Array): ZipEntry[] {
|
|
32
|
+
const entries: ZipEntry[] = [];
|
|
33
|
+
const dec = new TextDecoder();
|
|
34
|
+
let i = 0;
|
|
35
|
+
while (i + 4 <= buf.length) {
|
|
36
|
+
const sig = u32(buf, i);
|
|
37
|
+
if (sig !== 0x04034b50) break; // local file header; central dir / EOCD follow
|
|
38
|
+
const method = u16(buf, i + 8);
|
|
39
|
+
const compSize = u32(buf, i + 18);
|
|
40
|
+
const nameLen = u16(buf, i + 26);
|
|
41
|
+
const extraLen = u16(buf, i + 28);
|
|
42
|
+
const nameStart = i + 30;
|
|
43
|
+
const name = dec.decode(buf.subarray(nameStart, nameStart + nameLen));
|
|
44
|
+
const dataStart = nameStart + nameLen + extraLen;
|
|
45
|
+
const data = buf.subarray(dataStart, dataStart + compSize);
|
|
46
|
+
entries.push({ name, method, data });
|
|
47
|
+
i = dataStart + compSize;
|
|
48
|
+
}
|
|
49
|
+
if (entries.length === 0) throw new Error("dope: not a zip (no local file headers)");
|
|
50
|
+
return entries;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function inflate(entry: ZipEntry): Promise<Uint8Array> {
|
|
54
|
+
if (entry.method === 0) return entry.data.slice(); // STORED
|
|
55
|
+
if (entry.method === 8) {
|
|
56
|
+
// raw DEFLATE
|
|
57
|
+
if (typeof (globalThis as { DecompressionStream?: unknown }).DecompressionStream === "undefined") {
|
|
58
|
+
throw new Error("dope: DEFLATE zip entry but no DecompressionStream available");
|
|
59
|
+
}
|
|
60
|
+
const DS = (globalThis as unknown as { DecompressionStream: new (f: string) => unknown })
|
|
61
|
+
.DecompressionStream;
|
|
62
|
+
const ds = new DS("deflate-raw") as unknown as TransformStream<Uint8Array, Uint8Array>;
|
|
63
|
+
// Copy into a standalone ArrayBuffer-backed view for the Blob part.
|
|
64
|
+
const part = new Uint8Array(entry.data.length);
|
|
65
|
+
part.set(entry.data);
|
|
66
|
+
const stream = new Response(new Blob([part]).stream().pipeThrough(ds));
|
|
67
|
+
return new Uint8Array(await stream.arrayBuffer());
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`dope: unsupported zip compression method ${entry.method}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const ABS_OR_REMOTE = /^(?:[a-z][a-z0-9+.-]*:)?\/\/|^\//i;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read a `.dope` zip → the fully-inlined effect document (a parsed object).
|
|
76
|
+
* Resolves the effect JSON via manifest.json, then inlines relative `$ref`/asset
|
|
77
|
+
* paths under geometry/outlines from the zip's `assets/`.
|
|
78
|
+
*/
|
|
79
|
+
export async function readDopeZip(buf: Uint8Array): Promise<object> {
|
|
80
|
+
const entries = parseZip(buf);
|
|
81
|
+
const files = new Map<string, ZipEntry>();
|
|
82
|
+
for (const e of entries) files.set(e.name.replace(/^\.?\//, ""), e);
|
|
83
|
+
|
|
84
|
+
const textOf = async (name: string): Promise<string> => {
|
|
85
|
+
const e = files.get(name);
|
|
86
|
+
if (!e) throw new Error(`dope zip: missing entry "${name}"`);
|
|
87
|
+
return new TextDecoder().decode(await inflate(e));
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Locate the effect doc.
|
|
91
|
+
let effectPath: string | undefined;
|
|
92
|
+
if (files.has("manifest.json")) {
|
|
93
|
+
const manifest = JSON.parse(await textOf("manifest.json")) as {
|
|
94
|
+
effects?: { path?: string }[];
|
|
95
|
+
};
|
|
96
|
+
effectPath = manifest.effects?.[0]?.path?.replace(/^\.?\//, "");
|
|
97
|
+
}
|
|
98
|
+
if (!effectPath) {
|
|
99
|
+
effectPath = [...files.keys()].find((n) => /^effects\/.+\.json$/.test(n)) ?? "effect.json";
|
|
100
|
+
}
|
|
101
|
+
const doc = JSON.parse(await textOf(effectPath)) as Record<string, unknown>;
|
|
102
|
+
|
|
103
|
+
// Inline relative asset refs in geometry.outlines.*.sdf when stored as a path.
|
|
104
|
+
const geo = doc.geometry as { outlines?: Record<string, Record<string, unknown>> } | undefined;
|
|
105
|
+
if (geo?.outlines) {
|
|
106
|
+
for (const outline of Object.values(geo.outlines)) {
|
|
107
|
+
const sdf = outline.sdf as { data?: string } | undefined;
|
|
108
|
+
const ref = (sdf?.data ?? outline.sdfRef) as string | undefined;
|
|
109
|
+
if (typeof ref === "string" && !ref.startsWith("data:")) {
|
|
110
|
+
if (ABS_OR_REMOTE.test(ref)) {
|
|
111
|
+
throw new Error(`dope zip: outline asset must be a relative path, got "${ref}"`);
|
|
112
|
+
}
|
|
113
|
+
const e = files.get(ref.replace(/^\.?\//, ""));
|
|
114
|
+
if (!e) throw new Error(`dope zip: missing asset "${ref}"`);
|
|
115
|
+
const bytes = await inflate(e);
|
|
116
|
+
const b64 =
|
|
117
|
+
typeof Buffer !== "undefined"
|
|
118
|
+
? Buffer.from(bytes).toString("base64")
|
|
119
|
+
: btoa(String.fromCharCode(...bytes));
|
|
120
|
+
if (sdf) sdf.data = `data:application/octet-stream;base64,${b64}`;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return doc;
|
|
125
|
+
}
|