From 27b8aded6af61d7126713d7eb8c0e59c0c988573 Mon Sep 17 00:00:00 2001 From: YiJie Date: Sat, 5 Sep 2026 18:14:39 +0800 Subject: [PATCH 1/5] feat: expose independent Avatar renderer and editor entries --- package.json | 2 +- packages/avatar/package.json | 2 +- packages/react/README.md | 38 + packages/react/README.zh-Hans.md | 35 + packages/react/package.json | 36 +- packages/react/src/editor.scss | 62 ++ packages/react/src/editor.tsx | 93 +++ packages/react/src/index.tsx | 999 +----------------------- packages/react/src/pickers.scss | 94 +++ packages/react/src/pickers.tsx | 192 +++++ packages/react/src/renderer-style.scss | 3 + packages/react/src/renderer.scss | 34 + packages/react/src/renderer.tsx | 732 +++++++++++++++++ packages/react/src/style.scss | 187 +---- packages/react/vite.config.ts | 44 +- packages/vue/package.json | 2 +- packages/web/package.json | 2 +- scripts/check-react-package-entries.mjs | 105 +++ scripts/smoke-sdk-packages.mjs | 12 + 19 files changed, 1481 insertions(+), 1193 deletions(-) create mode 100644 packages/react/src/editor.scss create mode 100644 packages/react/src/editor.tsx create mode 100644 packages/react/src/pickers.scss create mode 100644 packages/react/src/pickers.tsx create mode 100644 packages/react/src/renderer-style.scss create mode 100644 packages/react/src/renderer.scss create mode 100644 packages/react/src/renderer.tsx create mode 100644 scripts/check-react-package-entries.mjs diff --git a/package.json b/package.json index 6423ba1..fe41e18 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@oneworks/avatar-assets", "type": "module", - "version": "1.0.0-rc.8", + "version": "1.0.0-rc.9", "packageManager": "pnpm@11.7.0", "private": true, "description": "OneWorks geometric 3D Avatar editor, runtime, and framework adapters", diff --git a/packages/avatar/package.json b/packages/avatar/package.json index 9d56919..22f3a8f 100644 --- a/packages/avatar/package.json +++ b/packages/avatar/package.json @@ -1,6 +1,6 @@ { "name": "@oneworks/avatar", - "version": "1.0.0-rc.8", + "version": "1.0.0-rc.9", "description": "Framework-neutral OneWorks 3D Avatar definitions, catalog, and animation runtime", "type": "module", "license": "MIT", diff --git a/packages/react/README.md b/packages/react/README.md index 16fddf8..c6187eb 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -4,6 +4,44 @@ React renderer and embeddable full editor for OneWorks 3D Avatar. +Display-only applications can select the renderer and its complete stylesheet: + +```tsx +import { Avatar } from '@oneworks/avatar-react/renderer' +import type { AvatarHandle } from '@oneworks/avatar-react/renderer' +import '@oneworks/avatar-react/renderer.css' +``` + +The renderer entry includes animation playback and capture, without importing the +editor, preset thumbnails, or editor styles. To load the editor only when its UI +is opened, keep the module and its stylesheet behind the same dynamic boundary: + +```tsx +import { lazy, Suspense } from 'react' + +const LazyEditor = lazy(async () => { + const [editor] = await Promise.all([ + import('@oneworks/avatar-react/editor'), + import('@oneworks/avatar-react/editor.css') + ]) + return { default: editor.AvatarEditor } +}) + +// Mount this boundary only while the editor is open. +Loading editor…}> + + +``` + +`editor.css` includes the full editor and renderer styles. Vite can bundle these +package subpaths into an application's own chunks. The package ships preview +images as relative, hashed asset files; deploy the complete consuming build so +those URLs remain available. Merely importing the editor module does not fetch +every preview image. Subpath exports select modules; dynamic imports determine +when they are loaded. + +The existing root entry, selectors, public types, and `style.css` remain supported: + ```tsx import { Avatar, AvatarEditor } from '@oneworks/avatar-react' import '@oneworks/avatar-react/style.css' diff --git a/packages/react/README.zh-Hans.md b/packages/react/README.zh-Hans.md index 5cc1618..5966399 100644 --- a/packages/react/README.zh-Hans.md +++ b/packages/react/README.zh-Hans.md @@ -4,6 +4,41 @@ OneWorks 3D Avatar 的 React 渲染组件与可嵌入完整编辑器。 +只展示头像时,使用独立的渲染入口及其完整样式: + +```tsx +import { Avatar } from '@oneworks/avatar-react/renderer' +import type { AvatarHandle } from '@oneworks/avatar-react/renderer' +import '@oneworks/avatar-react/renderer.css' +``` + +渲染入口保留动画播放和 capture 能力,不引用编辑器、预设缩略图或编辑器样式。 +需要编辑器时,把组件和样式放在同一个动态加载边界: + +```tsx +import { lazy, Suspense } from 'react' + +const LazyEditor = lazy(async () => { + const [editor] = await Promise.all([ + import('@oneworks/avatar-react/editor'), + import('@oneworks/avatar-react/editor.css') + ]) + return { default: editor.AvatarEditor } +}) + +// 仅在编辑器打开期间挂载。 +正在加载编辑器…}> + + +``` + +`editor.css` 包含完整编辑器及渲染样式。Vite 可以把这些包入口编译成业务应用自己的 +chunks。包内预览图片使用带内容 hash 的独立文件与相对 URL;部署时保留消费端的完整 +构建产物。单独导入编辑器模块不会请求全部预览图片。`exports` 定义入口,动态 import +决定加载时机。 + +原有根入口、选择器、公开类型与 `style.css` 保持兼容: + ```tsx import { Avatar, AvatarEditor } from '@oneworks/avatar-react' import '@oneworks/avatar-react/style.css' diff --git a/packages/react/package.json b/packages/react/package.json index dbb6493..0f94304 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,10 +1,12 @@ { "name": "@oneworks/avatar-react", - "version": "1.0.0-rc.8", + "version": "1.0.0-rc.9", "description": "React renderer and embeddable editor for OneWorks 3D Avatar", "type": "module", "license": "MIT", - "sideEffects": ["./dist/style.css"], + "sideEffects": [ + "./dist/*.css" + ], "repository": { "type": "git", "url": "https://github.com/oneworks-ai/avatar.git", @@ -20,9 +22,31 @@ "__oneworks__": "./src/style.scss", "default": "./dist/style.css" }, - "./package.json": "./package.json" + "./package.json": "./package.json", + "./renderer": { + "__oneworks__": "./src/renderer.tsx", + "types": "./dist/renderer.d.ts", + "default": "./dist/renderer.js" + }, + "./renderer.css": { + "__oneworks__": "./src/renderer-style.scss", + "default": "./dist/renderer.css" + }, + "./editor": { + "__oneworks__": "./src/editor.tsx", + "types": "./dist/editor.d.ts", + "default": "./dist/editor.js" + }, + "./editor.css": { + "__oneworks__": "./src/style.scss", + "default": "./dist/editor.css" + } }, - "files": ["dist", "README.md", "README.zh-Hans.md"], + "files": [ + "dist", + "README.md", + "README.zh-Hans.md" + ], "scripts": { "build": "vite build", "typecheck": "tsc -p tsconfig.json --noEmit --pretty false" @@ -34,5 +58,7 @@ "react": ">=18.3.0 <20", "react-dom": ">=18.3.0 <20" }, - "publishConfig": { "access": "public" } + "publishConfig": { + "access": "public" + } } diff --git a/packages/react/src/editor.scss b/packages/react/src/editor.scss new file mode 100644 index 0000000..4dabde4 --- /dev/null +++ b/packages/react/src/editor.scss @@ -0,0 +1,62 @@ +@use '@oneworks/route-layout/design-tokens.css'; + +.oneworks-avatar-editor { + box-sizing: border-box; + color: var(--text-color); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +.oneworks-avatar-editor * { + box-sizing: border-box; +} + +.oneworks-avatar-editor { + position: relative; + display: block; + width: 100%; + min-width: 0; + min-height: 640px; + overflow: hidden; + background: var(--bg-color); +} + +.oneworks-avatar-editor > .avatar-app { + min-height: inherit; +} + +.oneworks-avatar-editor > .avatar-app.dark { + --app-chrome-overlay-background: color-mix( + in srgb, + var(--bg-color) 42%, + transparent + ); + --bg-color: #141414; + --sub-bg-color: #262626; + --sub-sub-bg-color: #303030; + --text-color: #ffffff; + --sub-text-color: #8c8c8c; + --sub-sub-text-color: #a5a5a5; + --border-color: #303030; + --sub-border-color: #434343; + --sub-sub-border-color: #595959; + --placeholder-color: #575859; + --tag-bg: #1f1f1f; + --tag-hover-bg: #262626; + --code-bg: #1e293b; + --code-border: #334155; + --star-color: #ffb81c; + --primary-color: #e23f12; + --primary-soft-bg: color-mix( + in srgb, + var(--primary-color) 12%, + var(--bg-color) + ); + --success-color: #2dd4bf; + --warning-color: #f59e0b; + --primary-text-color: color-mix( + in srgb, + var(--primary-color) 82%, + var(--text-color) + ); + --danger-color: #f87171; +} diff --git a/packages/react/src/editor.tsx b/packages/react/src/editor.tsx new file mode 100644 index 0000000..243974c --- /dev/null +++ b/packages/react/src/editor.tsx @@ -0,0 +1,93 @@ +import './editor.scss' + +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react' +import type { HTMLAttributes } from 'react' +import { createDefaultAvatarDefinition } from '@oneworks/avatar' +import type { AvatarAnimationLibrary, AvatarDefinition } from '@oneworks/avatar' +import App from '../../../src/App' +import { AvatarLocaleProvider } from '../../../src/avatarLocale' +import type { AvatarLocale, AvatarTheme } from './renderer' + +export * from './pickers' +export type { AvatarLocale, AvatarTheme } from './renderer' + +export interface AvatarEditorHandle { + focus(): void + getDefinition(): AvatarDefinition + setDefinition(definition: AvatarDefinition): void +} + +export interface AvatarEditorProps extends Omit, 'children' | 'onChange'> { + readonly animationLibraries?: readonly AvatarAnimationLibrary[] + readonly defaultDefinition?: AvatarDefinition + readonly definition?: AvatarDefinition + readonly locale?: AvatarLocale + readonly onDefinitionChange?: (definition: AvatarDefinition) => void + readonly theme?: AvatarTheme +} + +export const AvatarEditor = forwardRef(function AvatarEditor({ + animationLibraries = [], + className, + defaultDefinition: defaultDefinitionProp, + definition, + locale = 'en', + onDefinitionChange, + theme = 'system', + ...divProps +}, ref) { + const defaultDefinitionRef = useRef() + if (defaultDefinitionRef.current == null) { + defaultDefinitionRef.current = defaultDefinitionProp ?? createDefaultAvatarDefinition() + } + const defaultDefinition = defaultDefinitionProp ?? defaultDefinitionRef.current + const containerRef = useRef(null) + const emittedRef = useRef() + const [internalDefinition, setInternalDefinition] = useState(definition ?? defaultDefinition) + const [revision, setRevision] = useState(0) + const value = internalDefinition + + useEffect(() => { + if (definition == null || definition === emittedRef.current) return + emittedRef.current = undefined + setInternalDefinition(definition) + setRevision(current => current + 1) + }, [definition]) + + const handleChange = useCallback((next: AvatarDefinition) => { + emittedRef.current = next + setInternalDefinition(next) + onDefinitionChange?.(next) + }, [onDefinitionChange]) + + useImperativeHandle(ref, () => ({ + focus: () => containerRef.current?.focus(), + getDefinition: () => emittedRef.current ?? value, + setDefinition: next => { + emittedRef.current = undefined + setInternalDefinition(next) + setRevision(current => current + 1) + onDefinitionChange?.(next) + } + }), [onDefinitionChange, value]) + + return ( +
+ + + +
+ ) +}) diff --git a/packages/react/src/index.tsx b/packages/react/src/index.tsx index b6a9a8d..f2a9ab8 100644 --- a/packages/react/src/index.tsx +++ b/packages/react/src/index.tsx @@ -1,998 +1,5 @@ +// Compatibility entry. Display-only consumers should use ./renderer. import './style.scss' -import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' -import type { CSSProperties, DragEvent, HTMLAttributes } from 'react' - -import { - anchorAvatarAnimationClip, - createDefaultAvatarDefinition, - getAvatarPalette, - mergeAvatarAnimationLibraries, - parseAvatarAnimationClip, - resolveAvatarAnimationClip, - resolveAvatarCoatPatternDecals, - resolveAvatarAnimationFrame, - resolveAvatarAnimationTimelineFrame, - resolveAvatarAnimationTracks, - resolveAvatarPaletteFromEntityParts, - validateAvatarAnimationTimeline -} from '@oneworks/avatar' -import type { - AvatarAnimationClip, - AvatarAnimationLibrary, - AvatarAnimationParameterValues, - AvatarAnimationRef, - AvatarAnimationTimeline, - AvatarAnimationTimelinePresetResolver, - AvatarDefinition -} from '@oneworks/avatar' - -import App from '../../../src/App' -import { InteractiveAvatar } from '../../../src/InteractiveAvatar' -import type { AvatarViewState } from '../../../src/InteractiveAvatar' -import { resolveAvatarFaceStyle } from '../../../src/avatarGeometry' -import { AvatarLocaleProvider } from '../../../src/avatarLocale' -import { renderAvatarPngBlob, renderAvatarSvgSource } from '../../../src/savedAvatarPresets' - -export type { - AvatarAnimationClip, - AvatarAnimationGroup, - AvatarAnimationKeyframe, - AvatarAnimationLibrary, - AvatarAnimationRef, - AvatarAnimationTimeline, - AvatarAnimationTimelineClipInstance, - AvatarAnimationTimelinePresetResolver, - AvatarAnimationTimelineTrack, - AvatarDefinition -} from '@oneworks/avatar' - -export type AvatarTheme = 'dark' | 'light' | 'system' -export type AvatarLocale = 'en' | 'zh-Hans' -export type AvatarCaptureOptions = { - readonly background?: string | 'transparent' - readonly format: 'png' | 'svg' - readonly frame?: 'circle' | 'rounded' | 'square' - readonly size: 128 | 256 | 512 -} - -export interface AvatarPlayOptions { - readonly parameterValues?: AvatarAnimationParameterValues - readonly playback?: 'loop' | 'once' - readonly speed?: number - readonly trackId?: string - readonly weight?: number -} - -export interface AvatarTrackInput { - readonly animation: AvatarAnimationClip | AvatarAnimationRef - readonly muted?: boolean - readonly parameterValues?: AvatarAnimationParameterValues - readonly solo?: boolean - readonly speed?: number - readonly timeMs?: number - readonly trackId: string - readonly weight?: number -} - -export interface AvatarTrackUpdate { - readonly muted?: boolean - readonly parameterValues?: AvatarAnimationParameterValues - readonly solo?: boolean - readonly speed?: number - readonly weight?: number -} - -export interface AvatarTimelineOptions { - readonly loop?: boolean - readonly playing?: boolean - readonly resolvePreset?: AvatarAnimationTimelinePresetResolver - readonly speed?: number - readonly timeMs?: number -} - -interface AvatarRuntimeTrack { - readonly clip: AvatarAnimationClip - elapsedBeforeStart: number - lastLoop: number - muted: boolean - parameterValues?: AvatarAnimationParameterValues - playing: boolean - solo: boolean - speed: number - startedAt: number - readonly trackId: string - weight: number -} - -interface AvatarRuntimeTimeline { - elapsedBeforeStart: number - lastLoop: number - loop: boolean - playing: boolean - readonly resolvePreset?: AvatarAnimationTimelinePresetResolver - speed: number - startedAt: number - readonly timeline: AvatarAnimationTimeline -} - -export interface AvatarHandle { - capture(options: AvatarCaptureOptions): Promise - getDefinition(): AvatarDefinition - pause(trackId?: string): void - play(animation: AvatarAnimationClip | AvatarAnimationRef, options?: AvatarPlayOptions): Promise - removeTrack(trackId: string): void - resume(trackId?: string): void - seek(timeMs: number, trackId?: string): void - setDefinition(definition: AvatarDefinition): void - setTimeline(timeline: AvatarAnimationTimeline | null, options?: AvatarTimelineOptions): void - setTracks(tracks: readonly AvatarTrackInput[]): Promise - stop(options?: { readonly reset?: boolean; readonly trackId?: string }): void - updateTrack(trackId: string, update: AvatarTrackUpdate): void -} - -export interface AvatarProps extends Omit, 'children' | 'onError'> { - readonly animation?: AvatarAnimationClip | AvatarAnimationRef | null - readonly animationLibraries?: readonly AvatarAnimationLibrary[] - readonly autoplay?: boolean - readonly definition?: AvatarDefinition - readonly interactive?: boolean - readonly onAnimationEnd?: () => void - readonly onAnimationLoop?: () => void - readonly onAnimationStart?: () => void - readonly onDefinitionChange?: (definition: AvatarDefinition) => void - readonly onError?: (error: Error) => void - readonly resolveTimelinePreset?: AvatarAnimationTimelinePresetResolver - readonly theme?: AvatarTheme - readonly timeline?: AvatarAnimationTimeline | null - readonly timelineLoop?: boolean - readonly timelineSpeed?: number - readonly timelineTimeMs?: number -} - -const resolveSystemTheme = () => ( - typeof window !== 'undefined' && - typeof window.matchMedia === 'function' && - window.matchMedia('(prefers-color-scheme: dark)').matches - ? 'dark' - : 'light' -) - -const applyView = (definition: AvatarDefinition, view: AvatarViewState): AvatarDefinition => ({ - ...definition, - scene: { ...definition.scene, view } -}) - -export const Avatar = forwardRef(function Avatar({ - animation = null, - animationLibraries = [], - autoplay = false, - className, - definition: definitionProp, - interactive = false, - onAnimationEnd, - onAnimationLoop, - onAnimationStart, - onDefinitionChange, - onError, - resolveTimelinePreset, - style, - theme = 'system', - timeline = null, - timelineLoop = false, - timelineSpeed = 1, - timelineTimeMs = 0, - ...divProps -}, ref) { - const defaultDefinitionRef = useRef() - if (defaultDefinitionRef.current == null) defaultDefinitionRef.current = createDefaultAvatarDefinition() - const definition = definitionProp ?? defaultDefinitionRef.current - const [currentDefinition, setCurrentDefinition] = useState(definition) - const [renderFrame, setRenderFrame] = useState<{ - readonly auxiliaryParts?: ReturnType['auxiliaryParts'] - readonly auxiliaryShapes?: ReturnType['auxiliaryShapes'] - readonly definition: AvatarDefinition - readonly partShapeMorphs?: ReturnType['partShapeMorphs'] - readonly partTransforms?: ReturnType['partTransforms'] - }>({ definition }) - const [systemTheme, setSystemTheme] = useState(resolveSystemTheme) - const containerRef = useRef(null) - const frameRef = useRef() - const animationTracksRef = useRef([]) - const animationTimelineRef = useRef(null) - const callbacksRef = useRef({ onAnimationEnd, onAnimationLoop, onAnimationStart, onError }) - callbacksRef.current = { onAnimationEnd, onAnimationLoop, onAnimationStart, onError } - const libraries = useMemo(() => - mergeAvatarAnimationLibraries([ - ...(currentDefinition.animations == null ? [] : [currentDefinition.animations]), - ...animationLibraries - ]), [animationLibraries, currentDefinition.animations]) - - useEffect(() => { - if (frameRef.current != null) cancelAnimationFrame(frameRef.current) - frameRef.current = undefined - animationTracksRef.current = [] - animationTimelineRef.current = null - setCurrentDefinition(definition) - setRenderFrame({ definition }) - }, [definition]) - - useEffect(() => { - if ( - theme !== 'system' || - typeof window === 'undefined' || - typeof window.matchMedia !== 'function' - ) return - const media = window.matchMedia('(prefers-color-scheme: dark)') - const handleChange = () => setSystemTheme(media.matches ? 'dark' : 'light') - media.addEventListener('change', handleChange) - return () => media.removeEventListener('change', handleChange) - }, [theme]) - - const commitDefinition = useCallback((next: AvatarDefinition) => { - setCurrentDefinition(next) - setRenderFrame({ definition: next }) - onDefinitionChange?.(next) - }, [onDefinitionChange]) - - const stopFrame = useCallback(() => { - if (frameRef.current != null) cancelAnimationFrame(frameRef.current) - frameRef.current = undefined - }, []) - - const resolveRequestedClip = useCallback((requested: AvatarAnimationClip | AvatarAnimationRef) => { - const selected = 'clipId' in requested ? resolveAvatarAnimationClip(libraries, requested) : requested - if (selected == null) throw new Error('Unknown OneWorks Avatar animation') - return anchorAvatarAnimationClip(currentDefinition, parseAvatarAnimationClip(selected)) - }, [currentDefinition, libraries]) - - const resolveTrackElapsed = (track: AvatarRuntimeTrack, now: number) => ( - track.elapsedBeforeStart + (track.playing ? (now - track.startedAt) * track.speed : 0) - ) - - const resolveTimelineElapsed = (runtime: AvatarRuntimeTimeline, now: number) => ( - runtime.elapsedBeforeStart + (runtime.playing ? (now - runtime.startedAt) * runtime.speed : 0) - ) - - const commitResolvedFrame = useCallback((frame: ReturnType) => { - setRenderFrame({ - auxiliaryParts: frame.auxiliaryParts, - auxiliaryShapes: frame.auxiliaryShapes, - definition: { ...currentDefinition, scene: frame.scene }, - partShapeMorphs: frame.partShapeMorphs, - partTransforms: frame.partTransforms - }) - }, [currentDefinition]) - - const composeAndCommit = useCallback((now: number) => { - const frame = resolveAvatarAnimationTracks( - currentDefinition, - animationTracksRef.current.map(track => ({ - clip: track.clip, - elapsedMs: resolveTrackElapsed(track, now), - muted: track.muted, - parameterValues: track.parameterValues, - preserveAuxiliaryPartIds: track.trackId === 'legacy', - solo: track.solo, - trackId: track.trackId, - weight: track.weight - })) - ) - commitResolvedFrame(frame) - return frame - }, [commitResolvedFrame, currentDefinition]) - - const composeTimelineAndCommit = useCallback((runtime: AvatarRuntimeTimeline, now: number) => { - const elapsedMs = resolveTimelineElapsed(runtime, now) - const timeMs = runtime.loop && runtime.timeline.durationMs > 0 - ? elapsedMs % runtime.timeline.durationMs - : Math.min(elapsedMs, runtime.timeline.durationMs) - const frame = resolveAvatarAnimationTimelineFrame( - currentDefinition, - runtime.timeline, - timeMs, - runtime.resolvePreset - ) - commitResolvedFrame(frame) - return { elapsedMs, frame } - }, [commitResolvedFrame, currentDefinition]) - - const tick = useCallback((now: number) => { - const timelineRuntime = animationTimelineRef.current - if (timelineRuntime != null) { - if (!timelineRuntime.playing) { - frameRef.current = undefined - return - } - const { elapsedMs } = composeTimelineAndCommit(timelineRuntime, now) - const loop = timelineRuntime.timeline.durationMs > 0 - ? Math.floor(elapsedMs / timelineRuntime.timeline.durationMs) - : 0 - if (timelineRuntime.loop && loop > timelineRuntime.lastLoop) { - timelineRuntime.lastLoop = loop - callbacksRef.current.onAnimationLoop?.() - } - if (!timelineRuntime.loop && elapsedMs >= timelineRuntime.timeline.durationMs) { - timelineRuntime.elapsedBeforeStart = timelineRuntime.timeline.durationMs - timelineRuntime.playing = false - callbacksRef.current.onAnimationEnd?.() - } - if (timelineRuntime.playing) frameRef.current = requestAnimationFrame(tick) - else frameRef.current = undefined - return - } - const tracks = animationTracksRef.current - if (!tracks.some(track => track.playing)) { - frameRef.current = undefined - return - } - tracks.forEach(track => { - if (!track.playing) return - const elapsed = resolveTrackElapsed(track, now) - const loop = track.clip.durationMs > 0 ? Math.floor(elapsed / track.clip.durationMs) : 0 - if (track.clip.playback === 'loop' && loop > track.lastLoop) { - track.lastLoop = loop - callbacksRef.current.onAnimationLoop?.() - } - if (track.clip.playback === 'once' && elapsed >= track.clip.durationMs) { - track.elapsedBeforeStart = track.clip.durationMs - track.playing = false - callbacksRef.current.onAnimationEnd?.() - } - }) - composeAndCommit(now) - if (tracks.some(track => track.playing)) frameRef.current = requestAnimationFrame(tick) - else frameRef.current = undefined - }, [composeAndCommit, composeTimelineAndCommit]) - - const ensureFrame = useCallback(() => { - if ( - frameRef.current == null && - (animationTimelineRef.current?.playing === true || animationTracksRef.current.some(track => track.playing)) - ) { - frameRef.current = requestAnimationFrame(tick) - } - }, [tick]) - - const setTracks = useCallback(async (inputs: readonly AvatarTrackInput[]) => { - animationTimelineRef.current = null - const now = performance.now() - const previous = new Map(animationTracksRef.current.map(track => [track.trackId, track])) - const resolved = inputs.map(input => { - const clip = resolveRequestedClip(input.animation) - const existing = previous.get(input.trackId) - const sameClip = existing != null && JSON.stringify(existing.clip) === JSON.stringify(clip) - return { - clip, - elapsedBeforeStart: sameClip ? resolveTrackElapsed(existing, now) : Math.max(input.timeMs ?? 0, 0), - lastLoop: sameClip ? existing.lastLoop : 0, - muted: input.muted ?? existing?.muted ?? false, - parameterValues: input.parameterValues ?? existing?.parameterValues, - playing: sameClip ? existing.playing : true, - solo: input.solo ?? existing?.solo ?? false, - speed: input.speed ?? existing?.speed ?? 1, - startedAt: now, - trackId: input.trackId, - weight: input.weight ?? existing?.weight ?? 1 - } satisfies AvatarRuntimeTrack - }) - resolveAvatarAnimationTracks(currentDefinition, resolved.map(track => ({ - clip: track.clip, - elapsedMs: track.elapsedBeforeStart, - muted: track.muted, - parameterValues: track.parameterValues, - solo: track.solo, - trackId: track.trackId, - weight: track.weight - }))) - animationTracksRef.current = resolved - composeAndCommit(now) - ensureFrame() - }, [composeAndCommit, currentDefinition, ensureFrame, resolveRequestedClip]) - - const play = useCallback(async ( - requested: AvatarAnimationClip | AvatarAnimationRef, - options: AvatarPlayOptions = {} - ) => { - try { - const selected = resolveRequestedClip(requested) - const clip = parseAvatarAnimationClip({ - ...selected, - playback: options.playback ?? selected.playback - }) - const trackId = options.trackId ?? 'legacy' - const reducedMotion = typeof window !== 'undefined' && - typeof window.matchMedia === 'function' && - window.matchMedia('(prefers-reduced-motion: reduce)').matches - const now = performance.now() - animationTimelineRef.current = null - const nextTrack: AvatarRuntimeTrack = { - clip, - elapsedBeforeStart: reducedMotion - ? clip.playback === 'once' ? clip.durationMs : clip.durationMs / 2 - : 0, - lastLoop: 0, - muted: false, - parameterValues: options.parameterValues, - playing: !reducedMotion, - solo: false, - speed: options.speed ?? 1, - startedAt: now, - trackId, - weight: options.weight ?? 1 - } - animationTracksRef.current = options.trackId == null - ? [nextTrack] - : [...animationTracksRef.current.filter(track => track.trackId !== trackId), nextTrack] - composeAndCommit(now) - callbacksRef.current.onAnimationStart?.() - if (reducedMotion) callbacksRef.current.onAnimationEnd?.() - else ensureFrame() - } catch (error) { - const resolved = error instanceof Error ? error : new Error('Unable to play OneWorks Avatar animation') - callbacksRef.current.onError?.(resolved) - throw resolved - } - }, [composeAndCommit, ensureFrame, resolveRequestedClip]) - const playRef = useRef(play) - playRef.current = play - - const setTimeline = useCallback(( - nextTimeline: AvatarAnimationTimeline | null, - options: AvatarTimelineOptions = {} - ) => { - stopFrame() - animationTracksRef.current = [] - if (nextTimeline == null) { - animationTimelineRef.current = null - setRenderFrame({ definition: currentDefinition }) - return - } - try { - validateAvatarAnimationTimeline(nextTimeline) - const speed = options.speed ?? 1 - if (!Number.isFinite(speed) || speed <= 0) { - throw new TypeError('Avatar animation timeline speed must be a finite number greater than 0') - } - const requestedOptionTimeMs = options.timeMs ?? 0 - if (!Number.isFinite(requestedOptionTimeMs)) { - throw new TypeError('Avatar animation timeline time must be a finite number') - } - const reducedMotion = typeof window !== 'undefined' && - typeof window.matchMedia === 'function' && - window.matchMedia('(prefers-reduced-motion: reduce)').matches - const requestedTimeMs = Math.min(Math.max(requestedOptionTimeMs, 0), nextTimeline.durationMs) - const playing = (options.playing ?? true) && !reducedMotion - const timeMs = reducedMotion && options.playing !== false - ? nextTimeline.durationMs / 2 - : requestedTimeMs - const now = performance.now() - const runtime: AvatarRuntimeTimeline = { - elapsedBeforeStart: timeMs, - lastLoop: nextTimeline.durationMs > 0 ? Math.floor(timeMs / nextTimeline.durationMs) : 0, - loop: options.loop ?? false, - playing, - resolvePreset: options.resolvePreset, - speed, - startedAt: now, - timeline: nextTimeline - } - animationTimelineRef.current = runtime - composeTimelineAndCommit(runtime, now) - if (options.playing !== false) callbacksRef.current.onAnimationStart?.() - if (reducedMotion && options.playing !== false) callbacksRef.current.onAnimationEnd?.() - else ensureFrame() - } catch (error) { - const resolved = error instanceof Error - ? error - : new Error('Unable to configure OneWorks Avatar animation timeline') - callbacksRef.current.onError?.(resolved) - throw resolved - } - }, [composeTimelineAndCommit, currentDefinition, ensureFrame, stopFrame]) - const setTimelineRef = useRef(setTimeline) - setTimelineRef.current = setTimeline - - const removeTrack = useCallback((trackId: string) => { - const now = performance.now() - animationTracksRef.current = animationTracksRef.current.filter(track => track.trackId !== trackId) - composeAndCommit(now) - if (!animationTracksRef.current.some(track => track.playing)) stopFrame() - }, [composeAndCommit, stopFrame]) - - const stop = useCallback((options: { readonly reset?: boolean; readonly trackId?: string } = {}) => { - const now = performance.now() - if (options.trackId != null) { - animationTracksRef.current = animationTracksRef.current.filter(track => track.trackId !== options.trackId) - composeAndCommit(now) - if (!animationTracksRef.current.some(track => track.playing)) stopFrame() - return - } - stopFrame() - animationTimelineRef.current = null - animationTracksRef.current = [] - if (options.reset !== false) setRenderFrame({ definition: currentDefinition }) - }, [composeAndCommit, currentDefinition, stopFrame]) - - useImperativeHandle(ref, () => ({ - capture: async options => { - const svg = containerRef.current?.querySelector('svg.interactive-avatar__canvas') - if (svg == null) throw new Error('Avatar is not ready to capture') - const captureOptions = { - background: options.background ?? renderFrame.definition.scene.camera.background, - frame: options.frame ?? renderFrame.definition.scene.camera.frame, - frameShadow: { - ...renderFrame.definition.scene.camera.frameShadow, - color: renderFrame.definition.scene.camera.frameShadow.color ?? getAvatarPalette( - renderFrame.definition.scene.appearance.paletteId - ).shadow - }, - pixelEffect: renderFrame.definition.scene.effects.pixelate, - showFrameShadow: renderFrame.definition.scene.camera.showFrameShadow - } - if (options.format === 'png') return renderAvatarPngBlob(svg, options.size, captureOptions) - return new Blob([await renderAvatarSvgSource(svg, options.size, captureOptions)], { - type: 'image/svg+xml;charset=utf-8' - }) - }, - getDefinition: () => currentDefinition, - pause: trackId => { - const now = performance.now() - const timelineRuntime = animationTimelineRef.current - if (timelineRuntime != null && trackId == null) { - timelineRuntime.elapsedBeforeStart = resolveTimelineElapsed(timelineRuntime, now) - timelineRuntime.playing = false - composeTimelineAndCommit(timelineRuntime, now) - stopFrame() - return - } - animationTracksRef.current.forEach(track => { - if (!track.playing || trackId != null && track.trackId !== trackId) return - track.elapsedBeforeStart = resolveTrackElapsed(track, now) - track.playing = false - }) - composeAndCommit(now) - if (!animationTracksRef.current.some(track => track.playing)) stopFrame() - }, - play, - removeTrack, - resume: trackId => { - const now = performance.now() - const timelineRuntime = animationTimelineRef.current - if (timelineRuntime != null && trackId == null) { - if (!timelineRuntime.playing) { - timelineRuntime.playing = true - timelineRuntime.startedAt = now - } - ensureFrame() - return - } - animationTracksRef.current.forEach(track => { - if (track.playing || trackId != null && track.trackId !== trackId) return - track.playing = true - track.startedAt = now - }) - ensureFrame() - }, - seek: (timeMs, trackId) => { - const now = performance.now() - const timelineRuntime = animationTimelineRef.current - if (timelineRuntime != null && trackId == null) { - timelineRuntime.elapsedBeforeStart = Math.min(Math.max(timeMs, 0), timelineRuntime.timeline.durationMs) - timelineRuntime.startedAt = now - composeTimelineAndCommit(timelineRuntime, now) - return - } - animationTracksRef.current.forEach(track => { - if (trackId != null && track.trackId !== trackId) return - track.elapsedBeforeStart = Math.max(timeMs, 0) - track.startedAt = now - }) - composeAndCommit(now) - }, - setDefinition: commitDefinition, - setTimeline, - setTracks, - stop, - updateTrack: (trackId, update) => { - const now = performance.now() - const track = animationTracksRef.current.find(candidate => candidate.trackId === trackId) - if (track == null) return - track.elapsedBeforeStart = resolveTrackElapsed(track, now) - track.startedAt = now - if (update.muted != null) track.muted = update.muted - if (update.parameterValues != null) track.parameterValues = update.parameterValues - if (update.solo != null) track.solo = update.solo - if (update.speed != null) track.speed = update.speed - if (update.weight != null) track.weight = update.weight - composeAndCommit(now) - } - }), [ - commitDefinition, - composeAndCommit, - currentDefinition, - ensureFrame, - play, - removeTrack, - renderFrame.definition, - setTracks, - setTimeline, - stop, - stopFrame - ]) - - useEffect(() => () => stopFrame(), [stopFrame]) - useEffect(() => { - if (timeline != null) { - setTimelineRef.current(timeline, { - loop: timelineLoop, - playing: autoplay, - resolvePreset: resolveTimelinePreset, - speed: timelineSpeed, - timeMs: timelineTimeMs - }) - } else if (autoplay && animation != null) void playRef.current(animation) - return () => stopFrame() - }, [ - animation, - autoplay, - resolveTimelinePreset, - stopFrame, - timeline, - timelineLoop, - timelineSpeed, - timelineTimeMs - ]) - - const scene = renderFrame.definition.scene - const renderEntityParts = renderFrame.partTransforms == null - ? scene.entity.parts - : currentDefinition.scene.entity.parts - const palette = useMemo( - () => resolveAvatarPaletteFromEntityParts( - getAvatarPalette(scene.appearance.paletteId), - renderEntityParts - ), - [renderEntityParts, scene.appearance.paletteId] - ) - const generatedCoatDecals = scene.appearance.coatPattern?.enabled - ? resolveAvatarCoatPatternDecals({ - entityParts: renderEntityParts, - entityPreset: scene.entity.preset, - palette, - paletteId: scene.appearance.paletteId, - pattern: scene.appearance.coatPattern - }) - : [] - const explicitDecalIds = new Set(scene.decals.map(decal => decal.id)) - const surfaceDecals = [ - ...generatedCoatDecals.filter(decal => !explicitDecalIds.has(decal.id)), - ...scene.decals - ] - const resolvedTheme = theme === 'system' ? systemTheme : theme - const frameShadowDirection = scene.camera.frameShadow.direction * Math.PI / 180 - const frameShadow = scene.camera.showFrameShadow - ? `${(Math.cos(frameShadowDirection) * scene.camera.frameShadow.distance).toFixed(2)}px ${ - (Math.sin(frameShadowDirection) * scene.camera.frameShadow.distance).toFixed(2) - }px ${scene.camera.frameShadow.softness}px color-mix(in srgb, ${ - scene.camera.frameShadow.color ?? palette.shadow - } ${scene.camera.frameShadow.opacity}%, transparent)` - : 'none' - const mergedStyle = { - '--oneworks-avatar-background': scene.camera.background === 'transparent' - ? 'transparent' - : scene.camera.background, - boxShadow: frameShadow, - ...style - } as CSSProperties - - return ( -
- { - stop() - commitDefinition(applyView(currentDefinition, view)) - }} - palette={palette} - partShapeMorphs={renderFrame.partShapeMorphs} - partTransforms={renderFrame.partTransforms} - pixelEffect={scene.effects.pixelate} - shadowStyle={scene.effects.faceShadow} - showAvatarShadow={scene.effects.showAvatarShadow} - showLight={scene.lighting.enabled} - showOutline={scene.effects.showOutline} - showShadow={scene.effects.showFaceShadow} - surfaceDecals={surfaceDecals} - viewState={scene.view} - /> -
- ) -}) - -export interface AvatarAnimationPickerOption { - readonly animation: AvatarAnimationClip | AvatarAnimationRef - readonly description?: string - readonly disabled?: boolean - readonly id: string - readonly keywords?: readonly string[] - readonly label: string - readonly previewUrl?: string -} - -export interface AvatarAnimationPickerProps extends Omit< - HTMLAttributes, - 'children' | 'onChange' | 'onDragStart' -> { - readonly draggable?: boolean - readonly emptyLabel?: string - readonly onChange?: (option: AvatarAnimationPickerOption) => void - readonly onOptionDragStart?: ( - option: AvatarAnimationPickerOption, - event: DragEvent - ) => void - readonly options: readonly AvatarAnimationPickerOption[] - readonly placeholder?: string - readonly searchable?: boolean - readonly value?: string | null -} - -export interface AvatarPresetPickerOption { - readonly definition: AvatarDefinition - readonly disabled?: boolean - readonly id: string - readonly keywords?: readonly string[] - readonly label: string - readonly previewUrl?: string -} - -export interface AvatarPresetPickerProps extends Omit, 'children' | 'onChange'> { - readonly emptyLabel?: string - readonly onChange?: (option: AvatarPresetPickerOption) => void - readonly options: readonly AvatarPresetPickerOption[] - readonly placeholder?: string - readonly searchable?: boolean - readonly theme?: AvatarTheme - readonly value?: string | null -} - -const matchesPickerQuery = ( - option: Pick, - query: string -) => { - const normalized = query.trim().toLocaleLowerCase() - return normalized === '' || [ - option.id, - option.label, - option.description ?? '', - ...(option.keywords ?? []) - ].some(value => value.toLocaleLowerCase().includes(normalized)) -} - -export function AvatarAnimationPicker({ - className, - draggable = false, - emptyLabel = 'No animations found', - onChange, - onOptionDragStart, - options, - placeholder = 'Search animations', - searchable = true, - value, - ...divProps -}: AvatarAnimationPickerProps) { - const [query, setQuery] = useState('') - const visibleOptions = useMemo( - () => options.filter(option => matchesPickerQuery(option, query)), - [options, query] - ) - return ( -
- {searchable - ? setQuery(event.currentTarget.value)} - placeholder={placeholder} - type='search' - value={query} - /> - : null} -
- {visibleOptions.map(option => ( - - ))} - {visibleOptions.length === 0 - ? {emptyLabel} - : null} -
-
- ) -} - -export function AvatarPresetPicker({ - className, - emptyLabel = 'No avatars found', - onChange, - options, - placeholder = 'Search avatars', - searchable = false, - theme = 'system', - value, - ...divProps -}: AvatarPresetPickerProps) { - const [query, setQuery] = useState('') - const visibleOptions = useMemo( - () => options.filter(option => matchesPickerQuery(option, query)), - [options, query] - ) - return ( -
- {searchable - ? setQuery(event.currentTarget.value)} - placeholder={placeholder} - type='search' - value={query} - /> - : null} -
- {visibleOptions.map(option => ( - - ))} - {visibleOptions.length === 0 - ? {emptyLabel} - : null} -
-
- ) -} - -export interface AvatarEditorHandle { - focus(): void - getDefinition(): AvatarDefinition - setDefinition(definition: AvatarDefinition): void -} - -export interface AvatarEditorProps extends Omit, 'children' | 'onChange'> { - readonly animationLibraries?: readonly AvatarAnimationLibrary[] - readonly defaultDefinition?: AvatarDefinition - readonly definition?: AvatarDefinition - readonly locale?: AvatarLocale - readonly onDefinitionChange?: (definition: AvatarDefinition) => void - readonly theme?: AvatarTheme -} - -export const AvatarEditor = forwardRef(function AvatarEditor({ - animationLibraries = [], - className, - defaultDefinition: defaultDefinitionProp, - definition, - locale = 'en', - onDefinitionChange, - theme = 'system', - ...divProps -}, ref) { - const defaultDefinitionRef = useRef() - if (defaultDefinitionRef.current == null) { - defaultDefinitionRef.current = defaultDefinitionProp ?? createDefaultAvatarDefinition() - } - const defaultDefinition = defaultDefinitionProp ?? defaultDefinitionRef.current - const containerRef = useRef(null) - const emittedRef = useRef() - const [internalDefinition, setInternalDefinition] = useState(definition ?? defaultDefinition) - const [revision, setRevision] = useState(0) - const value = internalDefinition - - useEffect(() => { - if (definition == null || definition === emittedRef.current) return - emittedRef.current = undefined - setInternalDefinition(definition) - setRevision(current => current + 1) - }, [definition]) - - const handleChange = useCallback((next: AvatarDefinition) => { - emittedRef.current = next - setInternalDefinition(next) - onDefinitionChange?.(next) - }, [onDefinitionChange]) - - useImperativeHandle(ref, () => ({ - focus: () => containerRef.current?.focus(), - getDefinition: () => emittedRef.current ?? value, - setDefinition: next => { - emittedRef.current = undefined - setInternalDefinition(next) - setRevision(current => current + 1) - onDefinitionChange?.(next) - } - }), [onDefinitionChange, value]) - - return ( -
- - - -
- ) -}) +export * from './renderer' +export * from './editor' diff --git a/packages/react/src/pickers.scss b/packages/react/src/pickers.scss new file mode 100644 index 0000000..062f770 --- /dev/null +++ b/packages/react/src/pickers.scss @@ -0,0 +1,94 @@ +.oneworks-avatar-picker { + display: grid; + gap: 8px; + min-width: 0; + color: var(--text-color, currentColor); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + 'Segoe UI', sans-serif; +} + +.oneworks-avatar-picker *, +.oneworks-avatar-picker *::before, +.oneworks-avatar-picker *::after { + box-sizing: border-box; +} + +.oneworks-avatar-picker__search { + width: 100%; + min-width: 0; + height: 36px; + padding: 0 12px; + color: inherit; + background: var(--sub-bg-color, transparent); + border: 1px solid var(--border-color, color-mix(in srgb, currentColor 18%, transparent)); + border-radius: 8px; + outline: none; +} + +.oneworks-avatar-picker__search:focus-visible { + border-color: var(--primary-color, #e23f12); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color, #e23f12) 20%, transparent); +} + +.oneworks-avatar-picker__grid { + display: grid; + grid-template-columns: repeat(auto-fill, 32px); + gap: 8px; + align-items: center; + min-width: 0; +} + +.oneworks-avatar-picker__option { + position: relative; + display: grid; + place-items: center; + width: 32px; + height: 32px; + min-width: 32px; + padding: 0; + overflow: hidden; + color: inherit; + background: transparent; + border: 1px solid transparent; + border-radius: 8px; + cursor: pointer; +} + +.oneworks-avatar-picker__option:hover:not(:disabled) { + border-color: var(--sub-border-color, color-mix(in srgb, currentColor 28%, transparent)); +} + +.oneworks-avatar-picker__option[data-selected='true'] { + border-color: var(--primary-color, #e23f12); + box-shadow: 0 0 0 1px var(--primary-color, #e23f12); +} + +.oneworks-avatar-picker__option:focus-visible { + outline: 2px solid var(--primary-color, #e23f12); + outline-offset: 2px; +} + +.oneworks-avatar-picker__option:disabled { + cursor: not-allowed; + opacity: .4; +} + +.oneworks-avatar-picker__option > img, +.oneworks-avatar-picker__avatar-preview { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.oneworks-avatar-picker__fallback { + font-size: 12px; + line-height: 1; +} + +.oneworks-avatar-picker__empty { + grid-column: 1 / -1; + color: var(--sub-text-color, color-mix(in srgb, currentColor 58%, transparent)); + font-size: 12px; +} diff --git a/packages/react/src/pickers.tsx b/packages/react/src/pickers.tsx new file mode 100644 index 0000000..63e28db --- /dev/null +++ b/packages/react/src/pickers.tsx @@ -0,0 +1,192 @@ +import './pickers.scss' + +import { useMemo, useState } from 'react' +import type { DragEvent, HTMLAttributes } from 'react' +import type { AvatarAnimationClip, AvatarAnimationRef, AvatarDefinition } from '@oneworks/avatar' +import { Avatar } from './renderer' +import type { AvatarTheme } from './renderer' + +export interface AvatarAnimationPickerOption { + readonly animation: AvatarAnimationClip | AvatarAnimationRef + readonly description?: string + readonly disabled?: boolean + readonly id: string + readonly keywords?: readonly string[] + readonly label: string + readonly previewUrl?: string +} + +export interface AvatarAnimationPickerProps extends Omit< + HTMLAttributes, + 'children' | 'onChange' | 'onDragStart' +> { + readonly draggable?: boolean + readonly emptyLabel?: string + readonly onChange?: (option: AvatarAnimationPickerOption) => void + readonly onOptionDragStart?: ( + option: AvatarAnimationPickerOption, + event: DragEvent + ) => void + readonly options: readonly AvatarAnimationPickerOption[] + readonly placeholder?: string + readonly searchable?: boolean + readonly value?: string | null +} + +export interface AvatarPresetPickerOption { + readonly definition: AvatarDefinition + readonly disabled?: boolean + readonly id: string + readonly keywords?: readonly string[] + readonly label: string + readonly previewUrl?: string +} + +export interface AvatarPresetPickerProps extends Omit, 'children' | 'onChange'> { + readonly emptyLabel?: string + readonly onChange?: (option: AvatarPresetPickerOption) => void + readonly options: readonly AvatarPresetPickerOption[] + readonly placeholder?: string + readonly searchable?: boolean + readonly theme?: AvatarTheme + readonly value?: string | null +} + +const matchesPickerQuery = ( + option: Pick, + query: string +) => { + const normalized = query.trim().toLocaleLowerCase() + return normalized === '' || [ + option.id, + option.label, + option.description ?? '', + ...(option.keywords ?? []) + ].some(value => value.toLocaleLowerCase().includes(normalized)) +} + +export function AvatarAnimationPicker({ + className, + draggable = false, + emptyLabel = 'No animations found', + onChange, + onOptionDragStart, + options, + placeholder = 'Search animations', + searchable = true, + value, + ...divProps +}: AvatarAnimationPickerProps) { + const [query, setQuery] = useState('') + const visibleOptions = useMemo( + () => options.filter(option => matchesPickerQuery(option, query)), + [options, query] + ) + return ( +
+ {searchable + ? setQuery(event.currentTarget.value)} + placeholder={placeholder} + type='search' + value={query} + /> + : null} +
+ {visibleOptions.map(option => ( + + ))} + {visibleOptions.length === 0 + ? {emptyLabel} + : null} +
+
+ ) +} + +export function AvatarPresetPicker({ + className, + emptyLabel = 'No avatars found', + onChange, + options, + placeholder = 'Search avatars', + searchable = false, + theme = 'system', + value, + ...divProps +}: AvatarPresetPickerProps) { + const [query, setQuery] = useState('') + const visibleOptions = useMemo( + () => options.filter(option => matchesPickerQuery(option, query)), + [options, query] + ) + return ( +
+ {searchable + ? setQuery(event.currentTarget.value)} + placeholder={placeholder} + type='search' + value={query} + /> + : null} +
+ {visibleOptions.map(option => ( + + ))} + {visibleOptions.length === 0 + ? {emptyLabel} + : null} +
+
+ ) +} + diff --git a/packages/react/src/renderer-style.scss b/packages/react/src/renderer-style.scss new file mode 100644 index 0000000..65613d4 --- /dev/null +++ b/packages/react/src/renderer-style.scss @@ -0,0 +1,3 @@ +// Complete renderer stylesheet without editor UI or global design tokens. +@use '../../../src/InteractiveAvatar'; +@use './renderer'; diff --git a/packages/react/src/renderer.scss b/packages/react/src/renderer.scss new file mode 100644 index 0000000..416a607 --- /dev/null +++ b/packages/react/src/renderer.scss @@ -0,0 +1,34 @@ +.oneworks-avatar { + box-sizing: border-box; + color: var(--text-color); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + 'Segoe UI', sans-serif; +} + +.oneworks-avatar * { + box-sizing: border-box; +} + +.oneworks-avatar { + position: relative; + display: block; + width: 100%; + aspect-ratio: 1; + overflow: hidden; + background: var(--oneworks-avatar-background, transparent); +} + +.oneworks-avatar[data-frame='rounded'] { + border-radius: 12.5%; +} + +.oneworks-avatar[data-frame='circle'] { + border-radius: 50%; +} + +.oneworks-avatar > .interactive-avatar { + width: 100%; + height: 100%; +} + diff --git a/packages/react/src/renderer.tsx b/packages/react/src/renderer.tsx new file mode 100644 index 0000000..7645b4b --- /dev/null +++ b/packages/react/src/renderer.tsx @@ -0,0 +1,732 @@ +import './renderer.scss' + +import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' +import type { CSSProperties, HTMLAttributes } from 'react' + +import { + anchorAvatarAnimationClip, + createDefaultAvatarDefinition, + getAvatarPalette, + mergeAvatarAnimationLibraries, + parseAvatarAnimationClip, + resolveAvatarAnimationClip, + resolveAvatarCoatPatternDecals, + resolveAvatarAnimationFrame, + resolveAvatarAnimationTimelineFrame, + resolveAvatarAnimationTracks, + resolveAvatarPaletteFromEntityParts, + validateAvatarAnimationTimeline +} from '@oneworks/avatar' +import type { + AvatarAnimationClip, + AvatarAnimationLibrary, + AvatarAnimationParameterValues, + AvatarAnimationRef, + AvatarAnimationTimeline, + AvatarAnimationTimelinePresetResolver, + AvatarDefinition +} from '@oneworks/avatar' + +import { InteractiveAvatar } from '../../../src/InteractiveAvatar' +import type { AvatarViewState } from '../../../src/InteractiveAvatar' +import { resolveAvatarFaceStyle } from '../../../src/avatarGeometry' +import { renderAvatarPngBlob, renderAvatarSvgSource } from '../../../src/savedAvatarPresets' + +export type { + AvatarAnimationClip, + AvatarAnimationGroup, + AvatarAnimationKeyframe, + AvatarAnimationLibrary, + AvatarAnimationRef, + AvatarAnimationTimeline, + AvatarAnimationTimelineClipInstance, + AvatarAnimationTimelinePresetResolver, + AvatarAnimationTimelineTrack, + AvatarDefinition +} from '@oneworks/avatar' + +export type AvatarTheme = 'dark' | 'light' | 'system' +export type AvatarLocale = 'en' | 'zh-Hans' +export type AvatarCaptureOptions = { + readonly background?: string | 'transparent' + readonly format: 'png' | 'svg' + readonly frame?: 'circle' | 'rounded' | 'square' + readonly size: 128 | 256 | 512 +} + +export interface AvatarPlayOptions { + readonly parameterValues?: AvatarAnimationParameterValues + readonly playback?: 'loop' | 'once' + readonly speed?: number + readonly trackId?: string + readonly weight?: number +} + +export interface AvatarTrackInput { + readonly animation: AvatarAnimationClip | AvatarAnimationRef + readonly muted?: boolean + readonly parameterValues?: AvatarAnimationParameterValues + readonly solo?: boolean + readonly speed?: number + readonly timeMs?: number + readonly trackId: string + readonly weight?: number +} + +export interface AvatarTrackUpdate { + readonly muted?: boolean + readonly parameterValues?: AvatarAnimationParameterValues + readonly solo?: boolean + readonly speed?: number + readonly weight?: number +} + +export interface AvatarTimelineOptions { + readonly loop?: boolean + readonly playing?: boolean + readonly resolvePreset?: AvatarAnimationTimelinePresetResolver + readonly speed?: number + readonly timeMs?: number +} + +interface AvatarRuntimeTrack { + readonly clip: AvatarAnimationClip + elapsedBeforeStart: number + lastLoop: number + muted: boolean + parameterValues?: AvatarAnimationParameterValues + playing: boolean + solo: boolean + speed: number + startedAt: number + readonly trackId: string + weight: number +} + +interface AvatarRuntimeTimeline { + elapsedBeforeStart: number + lastLoop: number + loop: boolean + playing: boolean + readonly resolvePreset?: AvatarAnimationTimelinePresetResolver + speed: number + startedAt: number + readonly timeline: AvatarAnimationTimeline +} + +export interface AvatarHandle { + capture(options: AvatarCaptureOptions): Promise + getDefinition(): AvatarDefinition + pause(trackId?: string): void + play(animation: AvatarAnimationClip | AvatarAnimationRef, options?: AvatarPlayOptions): Promise + removeTrack(trackId: string): void + resume(trackId?: string): void + seek(timeMs: number, trackId?: string): void + setDefinition(definition: AvatarDefinition): void + setTimeline(timeline: AvatarAnimationTimeline | null, options?: AvatarTimelineOptions): void + setTracks(tracks: readonly AvatarTrackInput[]): Promise + stop(options?: { readonly reset?: boolean; readonly trackId?: string }): void + updateTrack(trackId: string, update: AvatarTrackUpdate): void +} + +export interface AvatarProps extends Omit, 'children' | 'onError'> { + readonly animation?: AvatarAnimationClip | AvatarAnimationRef | null + readonly animationLibraries?: readonly AvatarAnimationLibrary[] + readonly autoplay?: boolean + readonly definition?: AvatarDefinition + readonly interactive?: boolean + readonly onAnimationEnd?: () => void + readonly onAnimationLoop?: () => void + readonly onAnimationStart?: () => void + readonly onDefinitionChange?: (definition: AvatarDefinition) => void + readonly onError?: (error: Error) => void + readonly resolveTimelinePreset?: AvatarAnimationTimelinePresetResolver + readonly theme?: AvatarTheme + readonly timeline?: AvatarAnimationTimeline | null + readonly timelineLoop?: boolean + readonly timelineSpeed?: number + readonly timelineTimeMs?: number +} + +const resolveSystemTheme = () => ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light' +) + +const applyView = (definition: AvatarDefinition, view: AvatarViewState): AvatarDefinition => ({ + ...definition, + scene: { ...definition.scene, view } +}) + +export const Avatar = forwardRef(function Avatar({ + animation = null, + animationLibraries = [], + autoplay = false, + className, + definition: definitionProp, + interactive = false, + onAnimationEnd, + onAnimationLoop, + onAnimationStart, + onDefinitionChange, + onError, + resolveTimelinePreset, + style, + theme = 'system', + timeline = null, + timelineLoop = false, + timelineSpeed = 1, + timelineTimeMs = 0, + ...divProps +}, ref) { + const defaultDefinitionRef = useRef() + if (defaultDefinitionRef.current == null) defaultDefinitionRef.current = createDefaultAvatarDefinition() + const definition = definitionProp ?? defaultDefinitionRef.current + const [currentDefinition, setCurrentDefinition] = useState(definition) + const [renderFrame, setRenderFrame] = useState<{ + readonly auxiliaryParts?: ReturnType['auxiliaryParts'] + readonly auxiliaryShapes?: ReturnType['auxiliaryShapes'] + readonly definition: AvatarDefinition + readonly partShapeMorphs?: ReturnType['partShapeMorphs'] + readonly partTransforms?: ReturnType['partTransforms'] + }>({ definition }) + const [systemTheme, setSystemTheme] = useState(resolveSystemTheme) + const containerRef = useRef(null) + const frameRef = useRef() + const animationTracksRef = useRef([]) + const animationTimelineRef = useRef(null) + const callbacksRef = useRef({ onAnimationEnd, onAnimationLoop, onAnimationStart, onError }) + callbacksRef.current = { onAnimationEnd, onAnimationLoop, onAnimationStart, onError } + const libraries = useMemo(() => + mergeAvatarAnimationLibraries([ + ...(currentDefinition.animations == null ? [] : [currentDefinition.animations]), + ...animationLibraries + ]), [animationLibraries, currentDefinition.animations]) + + useEffect(() => { + if (frameRef.current != null) cancelAnimationFrame(frameRef.current) + frameRef.current = undefined + animationTracksRef.current = [] + animationTimelineRef.current = null + setCurrentDefinition(definition) + setRenderFrame({ definition }) + }, [definition]) + + useEffect(() => { + if ( + theme !== 'system' || + typeof window === 'undefined' || + typeof window.matchMedia !== 'function' + ) return + const media = window.matchMedia('(prefers-color-scheme: dark)') + const handleChange = () => setSystemTheme(media.matches ? 'dark' : 'light') + media.addEventListener('change', handleChange) + return () => media.removeEventListener('change', handleChange) + }, [theme]) + + const commitDefinition = useCallback((next: AvatarDefinition) => { + setCurrentDefinition(next) + setRenderFrame({ definition: next }) + onDefinitionChange?.(next) + }, [onDefinitionChange]) + + const stopFrame = useCallback(() => { + if (frameRef.current != null) cancelAnimationFrame(frameRef.current) + frameRef.current = undefined + }, []) + + const resolveRequestedClip = useCallback((requested: AvatarAnimationClip | AvatarAnimationRef) => { + const selected = 'clipId' in requested ? resolveAvatarAnimationClip(libraries, requested) : requested + if (selected == null) throw new Error('Unknown OneWorks Avatar animation') + return anchorAvatarAnimationClip(currentDefinition, parseAvatarAnimationClip(selected)) + }, [currentDefinition, libraries]) + + const resolveTrackElapsed = (track: AvatarRuntimeTrack, now: number) => ( + track.elapsedBeforeStart + (track.playing ? (now - track.startedAt) * track.speed : 0) + ) + + const resolveTimelineElapsed = (runtime: AvatarRuntimeTimeline, now: number) => ( + runtime.elapsedBeforeStart + (runtime.playing ? (now - runtime.startedAt) * runtime.speed : 0) + ) + + const commitResolvedFrame = useCallback((frame: ReturnType) => { + setRenderFrame({ + auxiliaryParts: frame.auxiliaryParts, + auxiliaryShapes: frame.auxiliaryShapes, + definition: { ...currentDefinition, scene: frame.scene }, + partShapeMorphs: frame.partShapeMorphs, + partTransforms: frame.partTransforms + }) + }, [currentDefinition]) + + const composeAndCommit = useCallback((now: number) => { + const frame = resolveAvatarAnimationTracks( + currentDefinition, + animationTracksRef.current.map(track => ({ + clip: track.clip, + elapsedMs: resolveTrackElapsed(track, now), + muted: track.muted, + parameterValues: track.parameterValues, + preserveAuxiliaryPartIds: track.trackId === 'legacy', + solo: track.solo, + trackId: track.trackId, + weight: track.weight + })) + ) + commitResolvedFrame(frame) + return frame + }, [commitResolvedFrame, currentDefinition]) + + const composeTimelineAndCommit = useCallback((runtime: AvatarRuntimeTimeline, now: number) => { + const elapsedMs = resolveTimelineElapsed(runtime, now) + const timeMs = runtime.loop && runtime.timeline.durationMs > 0 + ? elapsedMs % runtime.timeline.durationMs + : Math.min(elapsedMs, runtime.timeline.durationMs) + const frame = resolveAvatarAnimationTimelineFrame( + currentDefinition, + runtime.timeline, + timeMs, + runtime.resolvePreset + ) + commitResolvedFrame(frame) + return { elapsedMs, frame } + }, [commitResolvedFrame, currentDefinition]) + + const tick = useCallback((now: number) => { + const timelineRuntime = animationTimelineRef.current + if (timelineRuntime != null) { + if (!timelineRuntime.playing) { + frameRef.current = undefined + return + } + const { elapsedMs } = composeTimelineAndCommit(timelineRuntime, now) + const loop = timelineRuntime.timeline.durationMs > 0 + ? Math.floor(elapsedMs / timelineRuntime.timeline.durationMs) + : 0 + if (timelineRuntime.loop && loop > timelineRuntime.lastLoop) { + timelineRuntime.lastLoop = loop + callbacksRef.current.onAnimationLoop?.() + } + if (!timelineRuntime.loop && elapsedMs >= timelineRuntime.timeline.durationMs) { + timelineRuntime.elapsedBeforeStart = timelineRuntime.timeline.durationMs + timelineRuntime.playing = false + callbacksRef.current.onAnimationEnd?.() + } + if (timelineRuntime.playing) frameRef.current = requestAnimationFrame(tick) + else frameRef.current = undefined + return + } + const tracks = animationTracksRef.current + if (!tracks.some(track => track.playing)) { + frameRef.current = undefined + return + } + tracks.forEach(track => { + if (!track.playing) return + const elapsed = resolveTrackElapsed(track, now) + const loop = track.clip.durationMs > 0 ? Math.floor(elapsed / track.clip.durationMs) : 0 + if (track.clip.playback === 'loop' && loop > track.lastLoop) { + track.lastLoop = loop + callbacksRef.current.onAnimationLoop?.() + } + if (track.clip.playback === 'once' && elapsed >= track.clip.durationMs) { + track.elapsedBeforeStart = track.clip.durationMs + track.playing = false + callbacksRef.current.onAnimationEnd?.() + } + }) + composeAndCommit(now) + if (tracks.some(track => track.playing)) frameRef.current = requestAnimationFrame(tick) + else frameRef.current = undefined + }, [composeAndCommit, composeTimelineAndCommit]) + + const ensureFrame = useCallback(() => { + if ( + frameRef.current == null && + (animationTimelineRef.current?.playing === true || animationTracksRef.current.some(track => track.playing)) + ) { + frameRef.current = requestAnimationFrame(tick) + } + }, [tick]) + + const setTracks = useCallback(async (inputs: readonly AvatarTrackInput[]) => { + animationTimelineRef.current = null + const now = performance.now() + const previous = new Map(animationTracksRef.current.map(track => [track.trackId, track])) + const resolved = inputs.map(input => { + const clip = resolveRequestedClip(input.animation) + const existing = previous.get(input.trackId) + const sameClip = existing != null && JSON.stringify(existing.clip) === JSON.stringify(clip) + return { + clip, + elapsedBeforeStart: sameClip ? resolveTrackElapsed(existing, now) : Math.max(input.timeMs ?? 0, 0), + lastLoop: sameClip ? existing.lastLoop : 0, + muted: input.muted ?? existing?.muted ?? false, + parameterValues: input.parameterValues ?? existing?.parameterValues, + playing: sameClip ? existing.playing : true, + solo: input.solo ?? existing?.solo ?? false, + speed: input.speed ?? existing?.speed ?? 1, + startedAt: now, + trackId: input.trackId, + weight: input.weight ?? existing?.weight ?? 1 + } satisfies AvatarRuntimeTrack + }) + resolveAvatarAnimationTracks(currentDefinition, resolved.map(track => ({ + clip: track.clip, + elapsedMs: track.elapsedBeforeStart, + muted: track.muted, + parameterValues: track.parameterValues, + solo: track.solo, + trackId: track.trackId, + weight: track.weight + }))) + animationTracksRef.current = resolved + composeAndCommit(now) + ensureFrame() + }, [composeAndCommit, currentDefinition, ensureFrame, resolveRequestedClip]) + + const play = useCallback(async ( + requested: AvatarAnimationClip | AvatarAnimationRef, + options: AvatarPlayOptions = {} + ) => { + try { + const selected = resolveRequestedClip(requested) + const clip = parseAvatarAnimationClip({ + ...selected, + playback: options.playback ?? selected.playback + }) + const trackId = options.trackId ?? 'legacy' + const reducedMotion = typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + const now = performance.now() + animationTimelineRef.current = null + const nextTrack: AvatarRuntimeTrack = { + clip, + elapsedBeforeStart: reducedMotion + ? clip.playback === 'once' ? clip.durationMs : clip.durationMs / 2 + : 0, + lastLoop: 0, + muted: false, + parameterValues: options.parameterValues, + playing: !reducedMotion, + solo: false, + speed: options.speed ?? 1, + startedAt: now, + trackId, + weight: options.weight ?? 1 + } + animationTracksRef.current = options.trackId == null + ? [nextTrack] + : [...animationTracksRef.current.filter(track => track.trackId !== trackId), nextTrack] + composeAndCommit(now) + callbacksRef.current.onAnimationStart?.() + if (reducedMotion) callbacksRef.current.onAnimationEnd?.() + else ensureFrame() + } catch (error) { + const resolved = error instanceof Error ? error : new Error('Unable to play OneWorks Avatar animation') + callbacksRef.current.onError?.(resolved) + throw resolved + } + }, [composeAndCommit, ensureFrame, resolveRequestedClip]) + const playRef = useRef(play) + playRef.current = play + + const setTimeline = useCallback(( + nextTimeline: AvatarAnimationTimeline | null, + options: AvatarTimelineOptions = {} + ) => { + stopFrame() + animationTracksRef.current = [] + if (nextTimeline == null) { + animationTimelineRef.current = null + setRenderFrame({ definition: currentDefinition }) + return + } + try { + validateAvatarAnimationTimeline(nextTimeline) + const speed = options.speed ?? 1 + if (!Number.isFinite(speed) || speed <= 0) { + throw new TypeError('Avatar animation timeline speed must be a finite number greater than 0') + } + const requestedOptionTimeMs = options.timeMs ?? 0 + if (!Number.isFinite(requestedOptionTimeMs)) { + throw new TypeError('Avatar animation timeline time must be a finite number') + } + const reducedMotion = typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + const requestedTimeMs = Math.min(Math.max(requestedOptionTimeMs, 0), nextTimeline.durationMs) + const playing = (options.playing ?? true) && !reducedMotion + const timeMs = reducedMotion && options.playing !== false + ? nextTimeline.durationMs / 2 + : requestedTimeMs + const now = performance.now() + const runtime: AvatarRuntimeTimeline = { + elapsedBeforeStart: timeMs, + lastLoop: nextTimeline.durationMs > 0 ? Math.floor(timeMs / nextTimeline.durationMs) : 0, + loop: options.loop ?? false, + playing, + resolvePreset: options.resolvePreset, + speed, + startedAt: now, + timeline: nextTimeline + } + animationTimelineRef.current = runtime + composeTimelineAndCommit(runtime, now) + if (options.playing !== false) callbacksRef.current.onAnimationStart?.() + if (reducedMotion && options.playing !== false) callbacksRef.current.onAnimationEnd?.() + else ensureFrame() + } catch (error) { + const resolved = error instanceof Error + ? error + : new Error('Unable to configure OneWorks Avatar animation timeline') + callbacksRef.current.onError?.(resolved) + throw resolved + } + }, [composeTimelineAndCommit, currentDefinition, ensureFrame, stopFrame]) + const setTimelineRef = useRef(setTimeline) + setTimelineRef.current = setTimeline + + const removeTrack = useCallback((trackId: string) => { + const now = performance.now() + animationTracksRef.current = animationTracksRef.current.filter(track => track.trackId !== trackId) + composeAndCommit(now) + if (!animationTracksRef.current.some(track => track.playing)) stopFrame() + }, [composeAndCommit, stopFrame]) + + const stop = useCallback((options: { readonly reset?: boolean; readonly trackId?: string } = {}) => { + const now = performance.now() + if (options.trackId != null) { + animationTracksRef.current = animationTracksRef.current.filter(track => track.trackId !== options.trackId) + composeAndCommit(now) + if (!animationTracksRef.current.some(track => track.playing)) stopFrame() + return + } + stopFrame() + animationTimelineRef.current = null + animationTracksRef.current = [] + if (options.reset !== false) setRenderFrame({ definition: currentDefinition }) + }, [composeAndCommit, currentDefinition, stopFrame]) + + useImperativeHandle(ref, () => ({ + capture: async options => { + const svg = containerRef.current?.querySelector('svg.interactive-avatar__canvas') + if (svg == null) throw new Error('Avatar is not ready to capture') + const captureOptions = { + background: options.background ?? renderFrame.definition.scene.camera.background, + frame: options.frame ?? renderFrame.definition.scene.camera.frame, + frameShadow: { + ...renderFrame.definition.scene.camera.frameShadow, + color: renderFrame.definition.scene.camera.frameShadow.color ?? getAvatarPalette( + renderFrame.definition.scene.appearance.paletteId + ).shadow + }, + pixelEffect: renderFrame.definition.scene.effects.pixelate, + showFrameShadow: renderFrame.definition.scene.camera.showFrameShadow + } + if (options.format === 'png') return renderAvatarPngBlob(svg, options.size, captureOptions) + return new Blob([await renderAvatarSvgSource(svg, options.size, captureOptions)], { + type: 'image/svg+xml;charset=utf-8' + }) + }, + getDefinition: () => currentDefinition, + pause: trackId => { + const now = performance.now() + const timelineRuntime = animationTimelineRef.current + if (timelineRuntime != null && trackId == null) { + timelineRuntime.elapsedBeforeStart = resolveTimelineElapsed(timelineRuntime, now) + timelineRuntime.playing = false + composeTimelineAndCommit(timelineRuntime, now) + stopFrame() + return + } + animationTracksRef.current.forEach(track => { + if (!track.playing || trackId != null && track.trackId !== trackId) return + track.elapsedBeforeStart = resolveTrackElapsed(track, now) + track.playing = false + }) + composeAndCommit(now) + if (!animationTracksRef.current.some(track => track.playing)) stopFrame() + }, + play, + removeTrack, + resume: trackId => { + const now = performance.now() + const timelineRuntime = animationTimelineRef.current + if (timelineRuntime != null && trackId == null) { + if (!timelineRuntime.playing) { + timelineRuntime.playing = true + timelineRuntime.startedAt = now + } + ensureFrame() + return + } + animationTracksRef.current.forEach(track => { + if (track.playing || trackId != null && track.trackId !== trackId) return + track.playing = true + track.startedAt = now + }) + ensureFrame() + }, + seek: (timeMs, trackId) => { + const now = performance.now() + const timelineRuntime = animationTimelineRef.current + if (timelineRuntime != null && trackId == null) { + timelineRuntime.elapsedBeforeStart = Math.min(Math.max(timeMs, 0), timelineRuntime.timeline.durationMs) + timelineRuntime.startedAt = now + composeTimelineAndCommit(timelineRuntime, now) + return + } + animationTracksRef.current.forEach(track => { + if (trackId != null && track.trackId !== trackId) return + track.elapsedBeforeStart = Math.max(timeMs, 0) + track.startedAt = now + }) + composeAndCommit(now) + }, + setDefinition: commitDefinition, + setTimeline, + setTracks, + stop, + updateTrack: (trackId, update) => { + const now = performance.now() + const track = animationTracksRef.current.find(candidate => candidate.trackId === trackId) + if (track == null) return + track.elapsedBeforeStart = resolveTrackElapsed(track, now) + track.startedAt = now + if (update.muted != null) track.muted = update.muted + if (update.parameterValues != null) track.parameterValues = update.parameterValues + if (update.solo != null) track.solo = update.solo + if (update.speed != null) track.speed = update.speed + if (update.weight != null) track.weight = update.weight + composeAndCommit(now) + } + }), [ + commitDefinition, + composeAndCommit, + currentDefinition, + ensureFrame, + play, + removeTrack, + renderFrame.definition, + setTracks, + setTimeline, + stop, + stopFrame + ]) + + useEffect(() => () => stopFrame(), [stopFrame]) + useEffect(() => { + if (timeline != null) { + setTimelineRef.current(timeline, { + loop: timelineLoop, + playing: autoplay, + resolvePreset: resolveTimelinePreset, + speed: timelineSpeed, + timeMs: timelineTimeMs + }) + } else if (autoplay && animation != null) void playRef.current(animation) + return () => stopFrame() + }, [ + animation, + autoplay, + resolveTimelinePreset, + stopFrame, + timeline, + timelineLoop, + timelineSpeed, + timelineTimeMs + ]) + + const scene = renderFrame.definition.scene + const renderEntityParts = renderFrame.partTransforms == null + ? scene.entity.parts + : currentDefinition.scene.entity.parts + const palette = useMemo( + () => resolveAvatarPaletteFromEntityParts( + getAvatarPalette(scene.appearance.paletteId), + renderEntityParts + ), + [renderEntityParts, scene.appearance.paletteId] + ) + const generatedCoatDecals = scene.appearance.coatPattern?.enabled + ? resolveAvatarCoatPatternDecals({ + entityParts: renderEntityParts, + entityPreset: scene.entity.preset, + palette, + paletteId: scene.appearance.paletteId, + pattern: scene.appearance.coatPattern + }) + : [] + const explicitDecalIds = new Set(scene.decals.map(decal => decal.id)) + const surfaceDecals = [ + ...generatedCoatDecals.filter(decal => !explicitDecalIds.has(decal.id)), + ...scene.decals + ] + const resolvedTheme = theme === 'system' ? systemTheme : theme + const frameShadowDirection = scene.camera.frameShadow.direction * Math.PI / 180 + const frameShadow = scene.camera.showFrameShadow + ? `${(Math.cos(frameShadowDirection) * scene.camera.frameShadow.distance).toFixed(2)}px ${ + (Math.sin(frameShadowDirection) * scene.camera.frameShadow.distance).toFixed(2) + }px ${scene.camera.frameShadow.softness}px color-mix(in srgb, ${ + scene.camera.frameShadow.color ?? palette.shadow + } ${scene.camera.frameShadow.opacity}%, transparent)` + : 'none' + const mergedStyle = { + '--oneworks-avatar-background': scene.camera.background === 'transparent' + ? 'transparent' + : scene.camera.background, + boxShadow: frameShadow, + ...style + } as CSSProperties + + return ( +
+ { + stop() + commitDefinition(applyView(currentDefinition, view)) + }} + palette={palette} + partShapeMorphs={renderFrame.partShapeMorphs} + partTransforms={renderFrame.partTransforms} + pixelEffect={scene.effects.pixelate} + shadowStyle={scene.effects.faceShadow} + showAvatarShadow={scene.effects.showAvatarShadow} + showLight={scene.lighting.enabled} + showOutline={scene.effects.showOutline} + showShadow={scene.effects.showFaceShadow} + surfaceDecals={surfaceDecals} + viewState={scene.view} + /> +
+ ) +}) + diff --git a/packages/react/src/style.scss b/packages/react/src/style.scss index 76db4ee..ac0b2cf 100644 --- a/packages/react/src/style.scss +++ b/packages/react/src/style.scss @@ -1,183 +1,4 @@ -@import '@oneworks/route-layout/design-tokens.css'; - -.oneworks-avatar, -.oneworks-avatar-editor { - box-sizing: border-box; - color: var(--text-color); - font-family: - Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, - 'Segoe UI', sans-serif; -} - -.oneworks-avatar *, -.oneworks-avatar-editor * { - box-sizing: border-box; -} - -.oneworks-avatar { - position: relative; - display: block; - width: 100%; - aspect-ratio: 1; - overflow: hidden; - background: var(--oneworks-avatar-background, transparent); -} - -.oneworks-avatar[data-frame='rounded'] { - border-radius: 12.5%; -} - -.oneworks-avatar[data-frame='circle'] { - border-radius: 50%; -} - -.oneworks-avatar > .interactive-avatar { - width: 100%; - height: 100%; -} - -.oneworks-avatar-editor { - position: relative; - display: block; - width: 100%; - min-width: 0; - min-height: 640px; - overflow: hidden; - background: var(--bg-color); -} - -.oneworks-avatar-editor > .avatar-app { - min-height: inherit; -} - -.oneworks-avatar-editor > .avatar-app.dark { - --app-chrome-overlay-background: color-mix( - in srgb, - var(--bg-color) 42%, - transparent - ); - --bg-color: #141414; - --sub-bg-color: #262626; - --sub-sub-bg-color: #303030; - --text-color: #ffffff; - --sub-text-color: #8c8c8c; - --sub-sub-text-color: #a5a5a5; - --border-color: #303030; - --sub-border-color: #434343; - --sub-sub-border-color: #595959; - --placeholder-color: #575859; - --tag-bg: #1f1f1f; - --tag-hover-bg: #262626; - --code-bg: #1e293b; - --code-border: #334155; - --star-color: #ffb81c; - --primary-color: #e23f12; - --primary-soft-bg: color-mix( - in srgb, - var(--primary-color) 12%, - var(--bg-color) - ); - --success-color: #2dd4bf; - --warning-color: #f59e0b; - --primary-text-color: color-mix( - in srgb, - var(--primary-color) 82%, - var(--text-color) - ); - --danger-color: #f87171; -} - -.oneworks-avatar-picker { - display: grid; - gap: 8px; - min-width: 0; - color: var(--text-color, currentColor); - font-family: - Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, - 'Segoe UI', sans-serif; -} - -.oneworks-avatar-picker *, -.oneworks-avatar-picker *::before, -.oneworks-avatar-picker *::after { - box-sizing: border-box; -} - -.oneworks-avatar-picker__search { - width: 100%; - min-width: 0; - height: 36px; - padding: 0 12px; - color: inherit; - background: var(--sub-bg-color, transparent); - border: 1px solid var(--border-color, color-mix(in srgb, currentColor 18%, transparent)); - border-radius: 8px; - outline: none; -} - -.oneworks-avatar-picker__search:focus-visible { - border-color: var(--primary-color, #e23f12); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color, #e23f12) 20%, transparent); -} - -.oneworks-avatar-picker__grid { - display: grid; - grid-template-columns: repeat(auto-fill, 32px); - gap: 8px; - align-items: center; - min-width: 0; -} - -.oneworks-avatar-picker__option { - position: relative; - display: grid; - place-items: center; - width: 32px; - height: 32px; - min-width: 32px; - padding: 0; - overflow: hidden; - color: inherit; - background: transparent; - border: 1px solid transparent; - border-radius: 8px; - cursor: pointer; -} - -.oneworks-avatar-picker__option:hover:not(:disabled) { - border-color: var(--sub-border-color, color-mix(in srgb, currentColor 28%, transparent)); -} - -.oneworks-avatar-picker__option[data-selected='true'] { - border-color: var(--primary-color, #e23f12); - box-shadow: 0 0 0 1px var(--primary-color, #e23f12); -} - -.oneworks-avatar-picker__option:focus-visible { - outline: 2px solid var(--primary-color, #e23f12); - outline-offset: 2px; -} - -.oneworks-avatar-picker__option:disabled { - cursor: not-allowed; - opacity: .4; -} - -.oneworks-avatar-picker__option > img, -.oneworks-avatar-picker__avatar-preview { - display: block; - width: 100%; - height: 100%; - object-fit: cover; -} - -.oneworks-avatar-picker__fallback { - font-size: 12px; - line-height: 1; -} - -.oneworks-avatar-picker__empty { - grid-column: 1 / -1; - color: var(--sub-text-color, color-mix(in srgb, currentColor 58%, transparent)); - font-size: 12px; -} +// Full compatibility stylesheet; renderer.css is the display-only alternative. +@use './editor'; +@use './renderer'; +@use './pickers'; diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index ec51329..369ad0c 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -1,12 +1,31 @@ import path from 'node:path' import react from '@vitejs/plugin-react' +import { compile } from 'sass' import { defineConfig } from 'vite' import dts from 'vite-plugin-dts' export default defineConfig({ plugins: [ react(), + { + name: 'avatar-public-stylesheets', + generateBundle: { + order: 'post', + handler(_options, bundle) { + // Vite's compatibility stylesheet contains all editor/component CSS. + // Display consumers get a separately compiled, complete renderer CSS. + const fullStyle = bundle['style.css'] + if (fullStyle?.type !== 'asset') throw new Error('Missing Avatar compatibility stylesheet') + this.emitFile({ type: 'asset', fileName: 'editor.css', source: fullStyle.source }) + this.emitFile({ + type: 'asset', + fileName: 'renderer.css', + source: compile(path.resolve(__dirname, 'src/renderer-style.scss'), { style: 'compressed' }).css + }) + } + } + }, dts({ aliasesExclude: ['@oneworks/avatar'], entryRoot: 'src', @@ -14,6 +33,8 @@ export default defineConfig({ tsconfigPath: path.resolve(__dirname, 'tsconfig.json') }) ], + // Relative URLs let a consuming Vite build relocate chunks and static assets. + base: './', resolve: { alias: { '@oneworks/avatar': path.resolve(__dirname, '../avatar/src/index.ts'), @@ -25,12 +46,25 @@ export default defineConfig({ }, build: { emptyOutDir: true, - lib: { - entry: path.resolve(__dirname, 'src/index.tsx'), - fileName: 'index', - formats: ['es'] - }, + // Vite library mode always inlines assets in this supported Vite version. + // Explicit ESM entries preserve the package API while emitting image files. + assetsInlineLimit: 0, + cssCodeSplit: false, + cssMinify: true, + minify: false, rollupOptions: { + input: { + index: path.resolve(__dirname, 'src/index.tsx'), + renderer: path.resolve(__dirname, 'src/renderer.tsx'), + editor: path.resolve(__dirname, 'src/editor.tsx') + }, + preserveEntrySignatures: 'strict', + output: { + format: 'es', + entryFileNames: '[name].js', + chunkFileNames: 'chunks/[name]-[hash].js', + assetFileNames: asset => asset.name?.endsWith('.css') ? 'style.css' : 'assets/[name]-[hash][extname]' + }, external: [ '@oneworks/avatar', 'react', diff --git a/packages/vue/package.json b/packages/vue/package.json index 3b369b1..70497d9 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@oneworks/avatar-vue", - "version": "1.0.0-rc.8", + "version": "1.0.0-rc.9", "description": "Vue renderer and embeddable editor for OneWorks 3D Avatar", "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index 4718dce..bab2a58 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@oneworks/avatar-web", - "version": "1.0.0-rc.8", + "version": "1.0.0-rc.9", "description": "Vanilla JavaScript runtime, editor mounts, and Web Components for OneWorks 3D Avatar", "type": "module", "license": "MIT", diff --git a/scripts/check-react-package-entries.mjs b/scripts/check-react-package-entries.mjs new file mode 100644 index 0000000..8a17011 --- /dev/null +++ b/scripts/check-react-package-entries.mjs @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict' +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +// Capture utilities may contain a short data-URL prefix; only complete embedded +// image literals indicate that a preview asset leaked into a JavaScript chunk. +const hasEmbeddedSvg = code => [...code.matchAll(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g)] + .some(([literal]) => /^['"]data:image\/svg\+xml/.test(literal) && literal.length > 200) + +// Exercise the installed tarball, without repository aliases or source conditions. +export async function checkReactPackageEntries(consumerDirectory) { + const { build } = await import(pathToFileURL(path.join(consumerDirectory, 'node_modules/vite/dist/node/index.js')).href) + const manifest = JSON.parse(await readFile(path.join(consumerDirectory, 'node_modules/@oneworks/avatar-react/package.json'), 'utf8')) + for (const name of ['renderer', 'editor']) { + const entry = manifest.exports[`./${name}`] + assert.equal(entry.default, `./dist/${name}.js`) + await readFile(path.join(consumerDirectory, 'node_modules/@oneworks/avatar-react', entry.types)) + } + + const rendererEntry = path.join(consumerDirectory, 'src/renderer-only.ts') + await writeFile(rendererEntry, ` +export { Avatar } from '@oneworks/avatar-react/renderer' +import '@oneworks/avatar-react/renderer.css' +`) + const buildEntry = async entry => { + const result = await build({ + root: consumerDirectory, + configFile: false, + publicDir: false, + base: './', + logLevel: 'error', + build: { + write: false, + assetsInlineLimit: 0, + cssCodeSplit: true, + rollupOptions: { input: entry, preserveEntrySignatures: 'strict' } + } + }) + return result.output + } + + const rendererOutput = await buildEntry(rendererEntry) + const rendererChunks = rendererOutput.filter(file => file.type === 'chunk') + const rendererBytes = rendererChunks.reduce((total, file) => total + Buffer.byteLength(file.code), 0) + assert.ok(rendererBytes < 750_000, `Display-only consumer is unexpectedly large: ${rendererBytes} bytes`) + for (const chunk of rendererChunks) { + assert.ok(!Object.keys(chunk.modules).some(id => /\/avatar-react\/dist\/(?:index|editor)\.js$/.test(id)), 'Renderer includes the compatibility/editor entry') + assert.ok(!hasEmbeddedSvg(chunk.code), 'Renderer includes inline preview SVGs') + } + const rendererAssets = rendererOutput.filter(file => file.type === 'asset') + assert.ok(rendererAssets.length > 0, 'Renderer CSS was removed as a side effect') + assert.ok(rendererAssets.every(file => file.fileName.endsWith('.css')), 'Renderer emitted editor image assets') + const rendererCss = rendererAssets.map(file => String(file.source)).join('\n') + assert.ok(Buffer.byteLength(rendererCss) < 16_000, 'Renderer contains full editor styles') + assert.ok(rendererCss.includes('.interactive-avatar') && rendererCss.includes('.oneworks-avatar')) + assert.ok(!rendererCss.includes('.avatar-app')) + + const lazyEntry = path.join(consumerDirectory, 'src/lazy-editor.ts') + await writeFile(lazyEntry, ` +export { Avatar } from '@oneworks/avatar-react/renderer' +import '@oneworks/avatar-react/renderer.css' +export async function loadEditor() { + const [module] = await Promise.all([ + import('@oneworks/avatar-react/editor'), + import('@oneworks/avatar-react/editor.css') + ]) + return module.AvatarEditor +} +`) + const lazyOutput = await buildEntry(lazyEntry) + const files = new Map(lazyOutput.map(file => [file.fileName, file])) + const initial = new Set() + const visit = filename => { + if (initial.has(filename)) return + initial.add(filename) + const file = files.get(filename) + if (file?.type === 'chunk') file.imports.forEach(visit) + } + lazyOutput.filter(file => file.type === 'chunk' && file.isEntry).forEach(file => visit(file.fileName)) + const editorChunk = lazyOutput.find(file => file.type === 'chunk' && Object.keys(file.modules).some(id => /\/avatar-react\/dist\/editor\.js$/.test(id))) + assert.ok(editorChunk, 'Packed editor subpath did not produce an editor chunk') + assert.ok(!initial.has(editorChunk.fileName), 'Editor is reachable through a startup static import') + const editorCss = lazyOutput.find(file => file.type === 'asset' && file.fileName.endsWith('.css') && String(file.source).includes('.avatar-app')) + assert.ok(editorCss, 'Packed editor CSS did not survive the consuming build') + for (const filename of initial) { + const chunk = files.get(filename) + if (chunk?.type === 'chunk') { + assert.ok(chunk.viteMetadata?.importedCss instanceof Set, 'Missing Vite stylesheet graph metadata') + assert.ok(!chunk.viteMetadata.importedCss.has(editorCss.fileName), 'Editor CSS is loaded by the startup graph') + } + } + const imageAssets = lazyOutput.filter(file => file.type === 'asset' && file.fileName.endsWith('.svg')) + assert.ok(imageAssets.length > 0, 'Editor images were not emitted as relocatable assets') + for (const file of lazyOutput.filter(file => file.type === 'chunk')) { + assert.ok(!hasEmbeddedSvg(file.code), 'Editor preview images were inlined into JavaScript') + for (const reference of file.referencedFiles) assert.ok(files.has(reference), `Missing referenced asset: ${reference}`) + } + const initialBytes = [...initial].reduce((total, name) => { + const file = files.get(name) + return total + (file?.type === 'chunk' ? Buffer.byteLength(file.code) : 0) + }, 0) + assert.ok(initialBytes < 750_000, `Lazy consumer startup graph is unexpectedly large: ${initialBytes} bytes`) + process.stdout.write(`Packed React entries passed: renderer ${rendererBytes} B JS / ${Buffer.byteLength(rendererCss)} B CSS; lazy startup ${initialBytes} B JS; ${imageAssets.length} external SVG assets.\n`) +} diff --git a/scripts/smoke-sdk-packages.mjs b/scripts/smoke-sdk-packages.mjs index 5277264..c550d69 100644 --- a/scripts/smoke-sdk-packages.mjs +++ b/scripts/smoke-sdk-packages.mjs @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { checkReactPackageEntries } from './check-react-package-entries.mjs' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const temporaryRoot = await mkdtemp(path.join(tmpdir(), 'oneworks-avatar-sdk-')) @@ -145,6 +146,10 @@ import { createApp } from 'vue' import { createDefaultAvatarDefinition } from '@oneworks/avatar' import type { AvatarAnimationLibrary } from '@oneworks/avatar' import { Avatar, AvatarEditor } from '@oneworks/avatar-react' +import { Avatar as DisplayAvatar } from '@oneworks/avatar-react/renderer' +import { AvatarEditor as EditorEntry } from '@oneworks/avatar-react/editor' +import type { AvatarHandle } from '@oneworks/avatar-react/renderer' +import type { AvatarEditorHandle } from '@oneworks/avatar-react/editor' import '@oneworks/avatar-react/style.css' import { createAvatar, createAvatarEditor } from '@oneworks/avatar-web' import { registerAvatarElements } from '@oneworks/avatar-web/elements' @@ -153,6 +158,12 @@ import { OneWorksAvatar, OneWorksAvatarEditor } from '@oneworks/avatar-vue' import '@oneworks/avatar-vue/style.css' const definition = createDefaultAvatarDefinition() +const rendererComponent: typeof Avatar = DisplayAvatar +const editorComponent: typeof AvatarEditor = EditorEntry +void rendererComponent +void editorComponent +void (null as unknown as AvatarHandle) +void (null as unknown as AvatarEditorHandle) const animations: AvatarAnimationLibrary = { id: 'support', groups: { @@ -205,6 +216,7 @@ editorElement.animationLibraries = [animations] run('pnpm', ['install'], consumerDirectory) run('pnpm', ['build'], consumerDirectory) + await checkReactPackageEntries(consumerDirectory) process.stdout.write('OneWorks Avatar SDK package smoke passed.\n') } finally { await rm(temporaryRoot, { force: true, recursive: true }) From 2cd0ed1c49237ad7224a53dc2a6ef06ce42cfba8 Mon Sep 17 00:00:00 2001 From: YiJie Date: Sat, 5 Sep 2026 18:31:24 +0800 Subject: [PATCH 2/5] fix: fetch preset previews only when displayed --- __tests__/AvatarControls.spec.ts | 22 ++++++++++++++++++++++ src/AvatarControls.tsx | 19 +------------------ 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/__tests__/AvatarControls.spec.ts b/__tests__/AvatarControls.spec.ts index 9cde669..b9927f8 100644 --- a/__tests__/AvatarControls.spec.ts +++ b/__tests__/AvatarControls.spec.ts @@ -405,6 +405,28 @@ describe('AvatarControls Seed authoring', () => { expect(browser?.querySelectorAll('[data-preview-static="true"]')).toHaveLength(reusedStatic.length + 1) }) + it('does not fetch the entire preset catalog in background when the editor opens', () => { + const idleCallbacks: Array<() => void> = [] + const frameCallbacks: FrameRequestCallback[] = [] + const image = vi.fn(function () { return document.createElement('img') }) + vi.stubGlobal('Image', image) + vi.stubGlobal('requestIdleCallback', vi.fn((callback: () => void) => idleCallbacks.push(callback))) + vi.stubGlobal('cancelIdleCallback', vi.fn()) + vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => frameCallbacks.push(callback))) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + act(() => root.render(createElement( + AvatarLocaleProvider, + { initialLocale: 'en', persist: false }, + createElement(AvatarControls, createProps()) + ))) + act(() => { + idleCallbacks.splice(0).forEach(callback => callback()) + frameCallbacks.splice(0).forEach(callback => callback(0)) + }) + expect(image).not.toHaveBeenCalled() + expect(host.querySelectorAll('[data-preview-source="prebuilt"] img').length).toBeGreaterThan(0) + }) + it('opens built-in avatar templates from prebuilt SVG snapshots without modeling them first', () => { const props = createProps() act(() => root.render(createElement( diff --git a/src/AvatarControls.tsx b/src/AvatarControls.tsx index 9eb831e..3f7d8e4 100644 --- a/src/AvatarControls.tsx +++ b/src/AvatarControls.tsx @@ -343,7 +343,7 @@ const DeferredEntityPresetPreview = memo(function DeferredEntityPresetPreview({ > {snapshot == null ? ready ? renderPreview() : null - : } + : } ) }, (previous, next) => ( @@ -1955,23 +1955,6 @@ export function AvatarControls({ if (buildDetailPage === 'presets' && presetBrowser != null) presetSearchRef.current?.focus() }, [buildDetailPage, presetBrowser]) - useEffect(() => { - if (lightAzimuth !== DEFAULT_AVATAR_PREVIEW_LIGHT.azimuth || - lightElevation !== DEFAULT_AVATAR_PREVIEW_LIGHT.elevation) return - const preload = () => Object.values(AVATAR_ENTITY_PRESET_SNAPSHOT_URLS).forEach(source => { - const image = new Image() - image.decoding = 'async' - image.src = source - }) - const idleWindow = window as AvatarPreviewIdleWindow - if (idleWindow.requestIdleCallback == null) { - const frame = window.requestAnimationFrame(preload) - return () => window.cancelAnimationFrame(frame) - } - const idle = idleWindow.requestIdleCallback(preload, { timeout: 600 }) - return () => idleWindow.cancelIdleCallback?.(idle) - }, [lightAzimuth, lightElevation]) - const renderEntityPresetPreview = ( preset: Exclude, eager = false From 0228a0c89dc8f919e8be9fdbaddbd83e1b720205 Mon Sep 17 00:00:00 2001 From: YiJie Date: Sat, 5 Sep 2026 18:40:18 +0800 Subject: [PATCH 3/5] fix: deduplicate adapter styles and preserve source CSS --- packages/react/package.json | 3 ++- packages/react/src/index.tsx | 2 -- packages/react/src/pickers.tsx | 1 - packages/react/src/renderer.scss | 1 - packages/react/src/renderer.tsx | 1 - 5 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/react/package.json b/packages/react/package.json index 0f94304..38ab0e1 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -5,7 +5,8 @@ "type": "module", "license": "MIT", "sideEffects": [ - "./dist/*.css" + "./dist/*.css", + "./src/**/*.scss" ], "repository": { "type": "git", diff --git a/packages/react/src/index.tsx b/packages/react/src/index.tsx index f2a9ab8..038e815 100644 --- a/packages/react/src/index.tsx +++ b/packages/react/src/index.tsx @@ -1,5 +1,3 @@ // Compatibility entry. Display-only consumers should use ./renderer. -import './style.scss' - export * from './renderer' export * from './editor' diff --git a/packages/react/src/pickers.tsx b/packages/react/src/pickers.tsx index 63e28db..29b05d1 100644 --- a/packages/react/src/pickers.tsx +++ b/packages/react/src/pickers.tsx @@ -189,4 +189,3 @@ export function AvatarPresetPicker({ ) } - diff --git a/packages/react/src/renderer.scss b/packages/react/src/renderer.scss index 416a607..f675562 100644 --- a/packages/react/src/renderer.scss +++ b/packages/react/src/renderer.scss @@ -31,4 +31,3 @@ width: 100%; height: 100%; } - diff --git a/packages/react/src/renderer.tsx b/packages/react/src/renderer.tsx index 7645b4b..8e09ad2 100644 --- a/packages/react/src/renderer.tsx +++ b/packages/react/src/renderer.tsx @@ -729,4 +729,3 @@ export const Avatar = forwardRef(function Avatar({ ) }) - From 1e3d523e6a3ed5eaac051a889ebc1d7b39ce86d0 Mon Sep 17 00:00:00 2001 From: YiJie Date: Sat, 5 Sep 2026 18:41:42 +0800 Subject: [PATCH 4/5] docs: teach lazy Avatar React entry integration --- .../references/developer-integration.md | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/skills/oneworks-avatar/references/developer-integration.md b/skills/oneworks-avatar/references/developer-integration.md index bc1a28b..a1a7149 100644 --- a/skills/oneworks-avatar/references/developer-integration.md +++ b/skills/oneworks-avatar/references/developer-integration.md @@ -27,7 +27,7 @@ pnpm add @oneworks/avatar-web@rc # Vanilla JS and opt-in elements - `@oneworks/avatar-vue`: `OneWorksAvatar`, full `OneWorksAvatarEditor`, exposed controllers, and emits. - `@oneworks/avatar-web`: `createAvatar`, `createAvatarEditor`, DOM events, and explicitly registered `` / `` elements. -Import styles from the selected adapter's `style.css` export. +React applications on SDK `1.0.0-rc.9` or later can use the independent `renderer` / `renderer.css` and `editor` / `editor.css` exports. Verify the installed version exposes these paths before recommending them. Vue, Web, and older React integrations retain the adapter's `style.css` export. ## Define a 3D avatar and animations @@ -71,8 +71,10 @@ A definition contains `scene` and may additionally carry a top-level `animations ## React ```tsx -import { Avatar, AvatarEditor } from '@oneworks/avatar-react' -import '@oneworks/avatar-react/style.css' +import { Avatar } from '@oneworks/avatar-react/renderer' +import '@oneworks/avatar-react/renderer.css' +import { AvatarEditor } from '@oneworks/avatar-react/editor' +import '@oneworks/avatar-react/editor.css' ``` +When editing is optional, import only `renderer` and `renderer.css` initially. Load the editor and its CSS together at the interaction boundary: + +```tsx +import { lazy, Suspense } from 'react' + +const LazyAvatarEditor = lazy(async () => { + const [module] = await Promise.all([ + import('@oneworks/avatar-react/editor'), + import('@oneworks/avatar-react/editor.css') + ]) + return { default: module.AvatarEditor } +}) + +// Mount this only when the user opens the editor. +Loading editor…}> + {editorOpen && } + +``` + +The root React export remains compatible, but imports from it or its full `style.css` can retain editor dependencies. `exports` selects an independent entry; the application's dynamic `import()` determines when it loads. Keep emitted chunks and image assets together at deployment and preserve their relative URLs. Validate the installed package with a production Vite build and browser requests; a small source entry alone does not prove deferred loading. + Use renderer refs for `play`, `pause`, `resume`, `seek`, `stop`, `capture`, `getDefinition`, and `setDefinition`. Use editor refs for `focus`, `getDefinition`, and `setDefinition`. React events are callback props. ## Vue From 9664cc68104e68528bb4dc573495e5ce3bbf402e Mon Sep 17 00:00:00 2001 From: YiJie Date: Sat, 5 Sep 2026 18:46:29 +0800 Subject: [PATCH 5/5] fix: package the editor project icon with relative assets --- scripts/check-react-package-entries.mjs | 1 + src/App.tsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/check-react-package-entries.mjs b/scripts/check-react-package-entries.mjs index 8a17011..594e2ab 100644 --- a/scripts/check-react-package-entries.mjs +++ b/scripts/check-react-package-entries.mjs @@ -92,6 +92,7 @@ export async function loadEditor() { } const imageAssets = lazyOutput.filter(file => file.type === 'asset' && file.fileName.endsWith('.svg')) assert.ok(imageAssets.length > 0, 'Editor images were not emitted as relocatable assets') + assert.ok(imageAssets.some(file => /favicon-.*\.svg$/.test(file.fileName)), 'Editor project icon still depends on the host site root') for (const file of lazyOutput.filter(file => file.type === 'chunk')) { assert.ok(!hasEmbeddedSvg(file.code), 'Editor preview images were inlined into JavaScript') for (const reference of file.referencedFiles) assert.ok(files.has(reference), `Missing referenced asset: ${reference}`) diff --git a/src/App.tsx b/src/App.tsx index 6f29116..0cd7fd8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import './App.scss' +import faviconUrl from '../public/favicon.svg?url' import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import type { CSSProperties, MutableRefObject, ReactNode } from 'react' @@ -6076,7 +6077,7 @@ function App({ title={t('Project')} onClick={() => setProjectSidebarOpen(open => !open)} > - + )