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.
@@ -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 both to loadHumanPackage (useHumanPackage does it for you).
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
- /** Download once into the cache directory; later sessions load offline. */
11
- async function cached(url: string): Promise<string> {
12
- dirReady ??= FileSystem.makeDirectoryAsync(DIR, { intermediates: true }).catch(() => {});
13
- await dirReady;
14
- const file = DIR + url.replace(/^https?:\/\//, '').replace(/[^a-zA-Z0-9._-]/g, '_');
15
- const info = await FileSystem.getInfoAsync(file);
16
- if (!info.exists) {
17
- const res = await FileSystem.downloadAsync(url, file);
18
- if (res.status !== 200) {
19
- await FileSystem.deleteAsync(file, { idempotent: true });
20
- throw new Error(`humanforge: ${res.status} for ${url}`);
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 file;
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
- tex.generateMipmaps = false;
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
- export const expoLoadOptions: LoadOptions = { fetch: expoFetch, loadTexture: expoTextureLoader };
79
+ /** Options for loadHumanPackage / preloadHumanPackage on Expo: disk cache + JPEG/PNG textures. */
80
+ export const expoLoadOptions: LoadOptions = { fetch: expoFetch, loadTexture: expoTextureLoader, textureFormat: 'mobile' };
@@ -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';
@@ -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
- textureSize?: '1k' | '2k';
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;
@@ -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.manifest, options), [given, pkg, JSON.stringify(options)]);
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-choices">
156
+ <div className="hf-tiles">
143
157
  {ctl.options.map((o) => (
144
- <button key={o.id ?? 'none'} className={`hf-choice ${o.id === v ? 'on' : ''}`} onClick={() => onChange(ctl.apply(config, o.id))}>
145
- {o.swatch && <i style={{ background: o.swatch }} />}
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-choices{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:6px}
178
- .hf-choice{display:flex;align-items:center;gap:6px;justify-content:center;border:1px solid #e1e3e7;background:#fff;border-radius:10px;padding:9px 8px;font:inherit;font-size:12px;cursor:pointer;text-align:center}
179
- .hf-choice:hover{border-color:#c5c8ce}
180
- .hf-choice.on{border-color:var(--hf-accent);box-shadow:inset 0 0 0 1px var(--hf-accent);font-weight:600}
181
- .hf-choice i{width:12px;height:12px;border-radius:50%;flex:none}
182
- .hf-swatches{display:flex;flex-wrap:wrap;gap:8px}
183
- .hf-swatch{width:28px;height:28px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px #d5d8dd;cursor:pointer;padding:0}
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}}