humanforge 0.3.0 → 0.5.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/README.md +117 -18
- package/package.json +5 -2
- package/src/human/catalog.ts +47 -0
- package/src/human/editor.ts +129 -20
- package/src/human/runtime.ts +177 -25
- package/src/index.ts +2 -0
- package/src/native/HumanEditor.tsx +29 -10
- package/src/native/HumanView.tsx +129 -25
- package/src/native/humanExpo.ts +39 -17
- package/src/native/index.ts +3 -3
- package/src/web/HumanCanvas.tsx +3 -2
- package/src/web/HumanEditor.tsx +33 -12
package/src/native/humanExpo.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
// React Native (Expo) adapters for the realistic human: a disk-cached fetcher and an
|
|
2
|
-
// expo-gl texture loader. Pass
|
|
2
|
+
// expo-gl texture loader. Pass expoLoadOptions to loadHumanPackage / preloadHumanPackage
|
|
3
|
+
// (useHumanPackage does it for you).
|
|
4
|
+
//
|
|
5
|
+
// expo-gl decodes images with stb_image, which has no WebP support, so these options select the
|
|
6
|
+
// JPEG/PNG mobile texture pack (manifest.mobileTextures, built by `npm run build:mobile`).
|
|
3
7
|
import * as FileSystem from 'expo-file-system/legacy';
|
|
4
8
|
import { Texture } from 'three';
|
|
5
9
|
import type { HumanTextureLoader, LoadOptions } from '../human/runtime';
|
|
@@ -7,20 +11,34 @@ import type { HumanTextureLoader, LoadOptions } from '../human/runtime';
|
|
|
7
11
|
const DIR = `${FileSystem.cacheDirectory ?? ''}humanforge/`;
|
|
8
12
|
let dirReady: Promise<void> | null = null;
|
|
9
13
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
const inflight = new Map<string, Promise<string>>();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Download once into the cache directory; later sessions load offline. Concurrent requests for
|
|
18
|
+
* the same URL share one download, and files are written to a temp name and moved into place, so
|
|
19
|
+
* an interrupted download never leaves a broken file in the cache.
|
|
20
|
+
*/
|
|
21
|
+
export function cached(url: string): Promise<string> {
|
|
22
|
+
let p = inflight.get(url);
|
|
23
|
+
if (!p) {
|
|
24
|
+
p = (async () => {
|
|
25
|
+
dirReady ??= FileSystem.makeDirectoryAsync(DIR, { intermediates: true }).catch(() => {});
|
|
26
|
+
await dirReady;
|
|
27
|
+
const file = DIR + url.replace(/^https?:\/\//, '').replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
28
|
+
if ((await FileSystem.getInfoAsync(file)).exists) return file;
|
|
29
|
+
const tmp = `${file}.${Date.now()}.part`;
|
|
30
|
+
const res = await FileSystem.downloadAsync(url, tmp);
|
|
31
|
+
if (res.status !== 200) {
|
|
32
|
+
await FileSystem.deleteAsync(tmp, { idempotent: true });
|
|
33
|
+
throw new Error(`humanforge: ${res.status} for ${url}`);
|
|
34
|
+
}
|
|
35
|
+
await FileSystem.moveAsync({ from: tmp, to: file });
|
|
36
|
+
return file;
|
|
37
|
+
})();
|
|
38
|
+
inflight.set(url, p);
|
|
39
|
+
p.catch(() => {}).finally(() => inflight.delete(url));
|
|
22
40
|
}
|
|
23
|
-
return
|
|
41
|
+
return p;
|
|
24
42
|
}
|
|
25
43
|
|
|
26
44
|
const B64 = new Uint8Array(128);
|
|
@@ -43,16 +61,20 @@ export const expoFetch = async (url: string): Promise<ArrayBuffer> =>
|
|
|
43
61
|
|
|
44
62
|
/**
|
|
45
63
|
* expo-gl decodes images natively when texImage2D receives `{ localUri }`, so we hand three
|
|
46
|
-
* a "data texture" whose data is the cached file (same trick expo-three uses).
|
|
64
|
+
* a "data texture" whose data is the cached file (same trick expo-three uses). The file must be
|
|
65
|
+
* JPEG or PNG (stb_image); WebP uploads silently fail and the mesh renders black.
|
|
47
66
|
*/
|
|
48
67
|
export const expoTextureLoader: HumanTextureLoader = async (url, size) => {
|
|
68
|
+
if (/\.webp$/i.test(url)) console.warn(`humanforge: expo-gl can't decode WebP (${url}); build/host the mobile texture pack`);
|
|
49
69
|
const localUri = await cached(url);
|
|
50
70
|
const tex = new Texture();
|
|
51
71
|
tex.image = { data: { localUri }, width: size, height: size };
|
|
52
72
|
(tex as any).isDataTexture = true;
|
|
53
|
-
|
|
73
|
+
// all pack textures are power-of-two squares, so mipmaps work and avoid shimmer at bust framing
|
|
74
|
+
tex.generateMipmaps = true;
|
|
54
75
|
tex.needsUpdate = true;
|
|
55
76
|
return tex;
|
|
56
77
|
};
|
|
57
78
|
|
|
58
|
-
|
|
79
|
+
/** Options for loadHumanPackage / preloadHumanPackage on Expo: disk cache + JPEG/PNG textures. */
|
|
80
|
+
export const expoLoadOptions: LoadOptions = { fetch: expoFetch, loadTexture: expoTextureLoader, textureFormat: 'mobile' };
|
package/src/native/index.ts
CHANGED
|
@@ -2,8 +2,8 @@ export { AvatarView } from './AvatarView';
|
|
|
2
2
|
export type { AvatarViewHandle, AvatarViewProps } from './AvatarView';
|
|
3
3
|
export { AvatarCreator } from './AvatarCreator';
|
|
4
4
|
export type { AvatarCreatorProps } from './AvatarCreator';
|
|
5
|
-
export { HumanView, useHumanPackage } from './HumanView';
|
|
6
|
-
export type { HumanViewHandle, HumanViewProps } from './HumanView';
|
|
7
|
-
export { expoFetch, expoLoadOptions, expoTextureLoader } from './humanExpo';
|
|
5
|
+
export { HumanView, preloadHumanPackage, useHumanPackage } from './HumanView';
|
|
6
|
+
export type { HumanViewHandle, HumanViewProps, UseHumanPackageOptions } from './HumanView';
|
|
7
|
+
export { cached as cachedFile, expoFetch, expoLoadOptions, expoTextureLoader } from './humanExpo';
|
|
8
8
|
export { HumanEditor } from './HumanEditor';
|
|
9
9
|
export type { HumanEditorProps } from './HumanEditor';
|
package/src/web/HumanCanvas.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
Vector3, WebGLRenderer,
|
|
6
6
|
} from 'three';
|
|
7
7
|
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
|
|
8
|
-
import { HumanAvatar, HumanConfig, HumanPackage } from '../human/runtime';
|
|
8
|
+
import { HumanAvatar, HumanConfig, HumanPackage, TextureTier } from '../human/runtime';
|
|
9
9
|
|
|
10
10
|
export interface HumanCanvasProps {
|
|
11
11
|
pkg: HumanPackage;
|
|
@@ -16,7 +16,8 @@ export interface HumanCanvasProps {
|
|
|
16
16
|
background?: string;
|
|
17
17
|
/** Drag to turn the avatar. Default true. */
|
|
18
18
|
rotatable?: boolean;
|
|
19
|
-
|
|
19
|
+
/** Texture tier: '512' | '1k' | '2k' (default '2k'). */
|
|
20
|
+
textureSize?: TextureTier;
|
|
20
21
|
className?: string;
|
|
21
22
|
style?: React.CSSProperties;
|
|
22
23
|
onReady?: (avatar: HumanAvatar) => void;
|
package/src/web/HumanEditor.tsx
CHANGED
|
@@ -35,7 +35,7 @@ export function HumanEditor({
|
|
|
35
35
|
pkg, options, editor: given, value, defaultValue, onChange, onSave, saveLabel = 'Save', selfie = false, randomize = true,
|
|
36
36
|
accentColor = '#1d1d1f', background = '#e9ecf1', className, style,
|
|
37
37
|
}: HumanEditorProps) {
|
|
38
|
-
const editor = useMemo(() => given ?? createHumanEditor(pkg
|
|
38
|
+
const editor = useMemo(() => given ?? createHumanEditor(pkg, options), [given, pkg, JSON.stringify(options)]);
|
|
39
39
|
const [inner, setInner] = useState<HumanConfig>(() => editor.normalize(defaultValue ?? null));
|
|
40
40
|
const config = value ? editor.normalize(value) : inner;
|
|
41
41
|
const [tab, setTab] = useState(0);
|
|
@@ -136,14 +136,27 @@ function Control({ ctl, config, onChange }: { ctl: EditorControl; config: HumanC
|
|
|
136
136
|
);
|
|
137
137
|
}
|
|
138
138
|
const v = ctl.value(config);
|
|
139
|
+
if (ctl.display === 'swatch') {
|
|
140
|
+
return (
|
|
141
|
+
<div className="hf-block">
|
|
142
|
+
<span className="hf-label">{ctl.label}</span>
|
|
143
|
+
<div className="hf-swatches">
|
|
144
|
+
{ctl.options.map((o) => (
|
|
145
|
+
<button key={o.id ?? 'none'} aria-label={o.label} title={o.label} className={`hf-swatch ${o.id === v ? 'on' : ''}`}
|
|
146
|
+
style={{ background: o.preview ? `center/cover url("${o.preview}") ${o.swatch ?? ''}` : o.swatch ?? '#eee' }}
|
|
147
|
+
onClick={() => onChange(ctl.apply(config, o.id))} />
|
|
148
|
+
))}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
139
153
|
return (
|
|
140
154
|
<div className="hf-block">
|
|
141
155
|
<span className="hf-label">{ctl.label}</span>
|
|
142
|
-
<div className="hf-
|
|
156
|
+
<div className="hf-tiles">
|
|
143
157
|
{ctl.options.map((o) => (
|
|
144
|
-
<button key={o.id ?? 'none'} className={`hf-
|
|
145
|
-
{o.
|
|
146
|
-
{o.label}
|
|
158
|
+
<button key={o.id ?? 'none'} aria-label={o.label} title={o.label} className={`hf-tile ${o.id === v ? 'on' : ''}`} onClick={() => onChange(ctl.apply(config, o.id))}>
|
|
159
|
+
{o.id === null ? <NoneIcon /> : o.preview ? <img src={o.preview} alt="" loading="lazy" draggable={false} /> : <span>{o.label}</span>}
|
|
147
160
|
</button>
|
|
148
161
|
))}
|
|
149
162
|
</div>
|
|
@@ -151,6 +164,14 @@ function Control({ ctl, config, onChange }: { ctl: EditorControl; config: HumanC
|
|
|
151
164
|
);
|
|
152
165
|
}
|
|
153
166
|
|
|
167
|
+
function NoneIcon() {
|
|
168
|
+
return (
|
|
169
|
+
<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true" fill="none" stroke="#9aa0a8" strokeWidth="1.8">
|
|
170
|
+
<circle cx="12" cy="12" r="9" /><path d="M5.6 18.4 18.4 5.6" />
|
|
171
|
+
</svg>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
154
175
|
const CSS = `
|
|
155
176
|
.hf-root{--hf-accent:#1d1d1f;container-type:inline-size;height:100%;min-height:480px}
|
|
156
177
|
.hf-editor{display:grid;grid-template-columns:minmax(0,1fr) 360px;height:100%;min-height:480px;font:13px/1.4 system-ui,-apple-system,sans-serif;color:#1d1d1f;background:#fff;border-radius:16px;overflow:hidden}
|
|
@@ -174,13 +195,13 @@ const CSS = `
|
|
|
174
195
|
.hf-label{font-size:12px;color:#666}
|
|
175
196
|
.hf-block{margin:12px 0}
|
|
176
197
|
.hf-block>.hf-label{display:block;margin-bottom:8px;font-weight:600;color:#333}
|
|
177
|
-
.hf-
|
|
178
|
-
.hf-
|
|
179
|
-
.hf-
|
|
180
|
-
.hf-
|
|
181
|
-
.hf-
|
|
182
|
-
.hf-swatches{display:flex;flex-wrap:wrap;gap:
|
|
183
|
-
.hf-swatch{width:
|
|
198
|
+
.hf-tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(72px,1fr));gap:8px}
|
|
199
|
+
.hf-tile{aspect-ratio:1;display:grid;place-items:center;border:1px solid #e1e3e7;background:#f4f5f7;border-radius:12px;padding:4px;cursor:pointer;overflow:hidden;font:inherit;font-size:11px;color:#555}
|
|
200
|
+
.hf-tile:hover{border-color:#c5c8ce}
|
|
201
|
+
.hf-tile.on{border-color:var(--hf-accent);box-shadow:inset 0 0 0 2px var(--hf-accent);background:#fff}
|
|
202
|
+
.hf-tile img{width:100%;height:100%;object-fit:contain;pointer-events:none}
|
|
203
|
+
.hf-swatches{display:flex;flex-wrap:wrap;gap:10px}
|
|
204
|
+
.hf-swatch{width:34px;height:34px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px #d5d8dd;cursor:pointer;padding:0}
|
|
184
205
|
.hf-swatch.on{box-shadow:0 0 0 2px var(--hf-accent)}
|
|
185
206
|
.hf-save{margin:12px 16px 16px;border:0;border-radius:12px;padding:12px;background:var(--hf-accent);color:#fff;font:inherit;font-weight:600;cursor:pointer}
|
|
186
207
|
@container (max-width:720px){.hf-editor{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(260px,52%) minmax(0,1fr)}.hf-panel{border-left:0;border-top:1px solid #eceef1}}
|