@airisu-dev/react-native-live2d-skia 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/Live2dSkia.podspec +159 -0
- package/README.md +64 -2
- package/android/CMakeLists.txt +113 -0
- package/android/build.gradle +223 -0
- package/android/fix-prefab.gradle +51 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/cpp/cpp-adapter.cpp +9 -0
- package/android/src/main/java/com/live2dskia/Live2dSkiaPackage.kt +20 -0
- package/app.plugin.js +2 -0
- package/cli/build/index.js +157 -0
- package/cpp/cubism/CubismCompat.hpp +26 -0
- package/cpp/cubism/CubismHelpers.hpp +27 -0
- package/cpp/cubism/CubismRendererStub.cpp +14 -0
- package/cpp/cubism/CubismSetup.cpp +54 -0
- package/cpp/cubism/CubismSetup.hpp +9 -0
- package/cpp/cubism/HeldParameterUpdater.hpp +25 -0
- package/cpp/cubism/Live2DUserModel.cpp +125 -0
- package/cpp/cubism/Live2DUserModel.hpp +57 -0
- package/cpp/cubism/Live2DUserModelAnimation.cpp +107 -0
- package/cpp/drawing/JsiUnwrap.hpp +25 -0
- package/cpp/drawing/MeshCache.cpp +75 -0
- package/cpp/drawing/MeshCache.hpp +45 -0
- package/cpp/drawing/SkiaDrawer.cpp +138 -0
- package/cpp/drawing/SkiaDrawer.hpp +42 -0
- package/cpp/hybrid/HybridLive2DModel.cpp +174 -0
- package/cpp/hybrid/HybridLive2DModel.hpp +64 -0
- package/cpp/hybrid/HybridLive2dSkia.cpp +10 -0
- package/cpp/hybrid/HybridLive2dSkia.hpp +11 -0
- package/ios/Bridge.h +8 -0
- package/nitro.json +20 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/android/Live2dSkia+autolinking.cmake +82 -0
- package/nitrogen/generated/android/Live2dSkia+autolinking.gradle +27 -0
- package/nitrogen/generated/android/Live2dSkiaOnLoad.cpp +49 -0
- package/nitrogen/generated/android/Live2dSkiaOnLoad.hpp +34 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/live2dskia/Live2dSkiaOnLoad.kt +35 -0
- package/nitrogen/generated/ios/Live2dSkia+autolinking.rb +62 -0
- package/nitrogen/generated/ios/Live2dSkia-Swift-Cxx-Bridge.cpp +17 -0
- package/nitrogen/generated/ios/Live2dSkia-Swift-Cxx-Bridge.hpp +27 -0
- package/nitrogen/generated/ios/Live2dSkia-Swift-Cxx-Umbrella.hpp +38 -0
- package/nitrogen/generated/ios/Live2dSkiaAutolinking.mm +35 -0
- package/nitrogen/generated/ios/Live2dSkiaAutolinking.swift +16 -0
- package/nitrogen/generated/shared/c++/DrawableData.hpp +116 -0
- package/nitrogen/generated/shared/c++/HybridLive2DModelSpec.cpp +39 -0
- package/nitrogen/generated/shared/c++/HybridLive2DModelSpec.hpp +86 -0
- package/nitrogen/generated/shared/c++/HybridLive2dSkiaSpec.cpp +21 -0
- package/nitrogen/generated/shared/c++/HybridLive2dSkiaSpec.hpp +66 -0
- package/nitrogen/generated/shared/c++/MotionPriority.hpp +80 -0
- package/package.json +146 -4
- package/plugin/build/index.d.ts +14 -0
- package/plugin/build/index.js +81 -0
- package/src/components/Live2D.tsx +131 -0
- package/src/components/index.ts +2 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/useLive2DGesture.ts +140 -0
- package/src/hooks/useLive2DModel.ts +47 -0
- package/src/index.ts +5 -0
- package/src/loader/cubismJson.ts +23 -0
- package/src/loader/index.ts +9 -0
- package/src/loader/loadModel.ts +194 -0
- package/src/loader/modelContext.ts +80 -0
- package/src/loader/modelUrl.ts +112 -0
- package/src/native.ts +4 -0
- package/src/rendering/drawer.ts +256 -0
- package/src/rendering/skia.ts +114 -0
- package/src/rendering/ticker/addToTicker.ts +52 -0
- package/src/rendering/ticker/constants.ts +11 -0
- package/src/rendering/ticker/frames.ts +35 -0
- package/src/rendering/ticker/index.ts +5 -0
- package/src/rendering/ticker/loop.ts +115 -0
- package/src/rendering/ticker/state.ts +65 -0
- package/src/rendering/ticker/stats.ts +36 -0
- package/src/rendering/ticker/types.ts +57 -0
- package/src/specs/react-native-live2d-skia.nitro.ts +88 -0
- package/src/utils/clock.ts +10 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
2
|
+
import { type Live2DModel, type Live2DModelSource, loadLive2DModel } from '../loader/loadModel'
|
|
3
|
+
import type { Live2DModelContext } from '../loader/modelContext'
|
|
4
|
+
import type { Live2DLoadOptions } from '../loader/modelUrl'
|
|
5
|
+
|
|
6
|
+
export interface Live2DModelState {
|
|
7
|
+
/** The loaded model, `null` while loading or after an error. */
|
|
8
|
+
model: Live2DModel | null
|
|
9
|
+
/** Why the last load failed, e.g. a network error for a URL source. */
|
|
10
|
+
error: Error | null
|
|
11
|
+
loading: boolean
|
|
12
|
+
/** Loads the model again, e.g. after an error. */
|
|
13
|
+
retry: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Loads a model on mount. `options` apply to remote files (see `Live2DLoadOptions`). */
|
|
17
|
+
export function useLive2DModel(
|
|
18
|
+
source: Live2DModelSource | Live2DModelContext | string,
|
|
19
|
+
options?: Live2DLoadOptions,
|
|
20
|
+
): Live2DModelState {
|
|
21
|
+
const [model, setModel] = useState<Live2DModel | null>(null)
|
|
22
|
+
const [error, setError] = useState<Error | null>(null)
|
|
23
|
+
const [attempt, setAttempt] = useState(0)
|
|
24
|
+
// Read once: remount to load another model. Wrapped because a require.context
|
|
25
|
+
// is a function, which useState would call as an initializer.
|
|
26
|
+
const [initialSource] = useState(() => source)
|
|
27
|
+
const [initialOptions] = useState(options)
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
let cancelled = false
|
|
31
|
+
loadLive2DModel(initialSource, initialOptions).then(
|
|
32
|
+
(m) => !cancelled && setModel(m),
|
|
33
|
+
(e: unknown) => !cancelled && setError(e instanceof Error ? e : new Error(String(e))),
|
|
34
|
+
)
|
|
35
|
+
return () => {
|
|
36
|
+
cancelled = true
|
|
37
|
+
}
|
|
38
|
+
}, [initialSource, initialOptions, attempt])
|
|
39
|
+
|
|
40
|
+
const retry = useCallback(() => {
|
|
41
|
+
setError(null)
|
|
42
|
+
setModel(null)
|
|
43
|
+
setAttempt((n) => n + 1)
|
|
44
|
+
}, [])
|
|
45
|
+
|
|
46
|
+
return { model, error, loading: !model && !error, retry }
|
|
47
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Serializes a parsed Cubism JSON file (model3, physics3, motion3, ...) for the
|
|
2
|
+
// Cubism Framework's own JSON parser, which is stricter than JSON:
|
|
3
|
+
// - a number must be followed by a newline or a comma, never `}`, `]` or a space
|
|
4
|
+
// (CubismJson::ParseNumeric), so every value goes on its own line;
|
|
5
|
+
// - exponents aren't supported, so numbers like 1e-7 are written out in full.
|
|
6
|
+
// Files from Cubism Editor already look like this; `JSON.stringify(x)` does not.
|
|
7
|
+
|
|
8
|
+
const PLACEHOLDER = '\u0000num:'
|
|
9
|
+
|
|
10
|
+
function plainNumber(n: number): string {
|
|
11
|
+
const s = String(n)
|
|
12
|
+
if (!/e/i.test(s)) return s
|
|
13
|
+
// toFixed(20) keeps far more precision than the Framework's float32 parser reads.
|
|
14
|
+
return n.toFixed(20).replace(/\.?0+$/, '')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function toCubismJson(value: unknown): string {
|
|
18
|
+
return JSON.stringify(
|
|
19
|
+
value,
|
|
20
|
+
(_key, v) => (typeof v === 'number' && /e/i.test(String(v)) ? PLACEHOLDER + plainNumber(v) : v),
|
|
21
|
+
'\t',
|
|
22
|
+
).replace(/"\\u0000num:([^"]+)"/g, '$1')
|
|
23
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { Asset } from 'expo-asset'
|
|
2
|
+
import { File } from 'expo-file-system'
|
|
3
|
+
import { Live2dSkia } from '../native'
|
|
4
|
+
import {
|
|
5
|
+
type CanvasInfo,
|
|
6
|
+
createDrawer,
|
|
7
|
+
createNativeDrawer,
|
|
8
|
+
setNativeTextures,
|
|
9
|
+
} from '../rendering/drawer'
|
|
10
|
+
import {
|
|
11
|
+
FilterMode,
|
|
12
|
+
MipmapMode,
|
|
13
|
+
type SkImage,
|
|
14
|
+
Skia,
|
|
15
|
+
type SkPicture,
|
|
16
|
+
type SkShader,
|
|
17
|
+
TileMode,
|
|
18
|
+
} from '../rendering/skia'
|
|
19
|
+
import type {
|
|
20
|
+
MotionPriority,
|
|
21
|
+
Live2DModel as NativeModel,
|
|
22
|
+
} from '../specs/react-native-live2d-skia.nitro'
|
|
23
|
+
import { toCubismJson } from './cubismJson'
|
|
24
|
+
import { isModelContext, type Live2DModelContext, sourceFromContext } from './modelContext'
|
|
25
|
+
import { fetchBytes, isRemote, type Live2DLoadOptions, redactUrl, sourceFromUrl } from './modelUrl'
|
|
26
|
+
|
|
27
|
+
export type AssetSource = number | string
|
|
28
|
+
|
|
29
|
+
/** A parsed Cubism JSON file, e.g. `require('../model.physics3.json')`. */
|
|
30
|
+
export type CubismJson = object
|
|
31
|
+
|
|
32
|
+
export interface Live2DModelSource {
|
|
33
|
+
/** Parsed `model3.json`. */
|
|
34
|
+
settings: CubismJson
|
|
35
|
+
moc: AssetSource
|
|
36
|
+
/** In the order of `FileReferences.Textures`. */
|
|
37
|
+
textures: AssetSource[]
|
|
38
|
+
physics?: CubismJson
|
|
39
|
+
pose?: CubismJson
|
|
40
|
+
/** By group, in the order of `FileReferences.Motions`. A random `Idle` motion plays when nothing else does. */
|
|
41
|
+
motions?: Record<string, CubismJson[]>
|
|
42
|
+
expressions?: Record<string, CubismJson>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A loaded model. Points are in model pixels: native canvas size, origin top-left, like `picture`. */
|
|
46
|
+
export interface Live2DModel {
|
|
47
|
+
/**
|
|
48
|
+
* Plays a motion of `group`, a random one without `index`. Returns false
|
|
49
|
+
* if there's no such motion or one with a higher priority is playing.
|
|
50
|
+
*/
|
|
51
|
+
motion(group: string, index?: number, priority?: MotionPriority): boolean
|
|
52
|
+
/** Fades to an expression, a random one without `name`. Returns false if there's none. */
|
|
53
|
+
expression(name?: string): boolean
|
|
54
|
+
resetExpression(): void
|
|
55
|
+
/** Holds a parameter at `value`, over motions, until `resetParameter`. */
|
|
56
|
+
setParameter(id: string, value: number): void
|
|
57
|
+
resetParameter(id: string): void
|
|
58
|
+
focus(x: number, y: number): void
|
|
59
|
+
resetFocus(): void
|
|
60
|
+
/** Names of the `model3.json` hit areas at a point, e.g. `['Head', 'Body']`. */
|
|
61
|
+
hitTest(x: number, y: number): string[]
|
|
62
|
+
motionGroups: string[]
|
|
63
|
+
expressionNames: string[]
|
|
64
|
+
|
|
65
|
+
/** Unique per loaded model: <Live2D>s showing the same model share its frames. */
|
|
66
|
+
id: number
|
|
67
|
+
/** Native model. Thread-safe. */
|
|
68
|
+
native: NativeModel
|
|
69
|
+
/** The model drawn at load, at its native canvas size, origin top-left. */
|
|
70
|
+
picture: SkPicture
|
|
71
|
+
width: number
|
|
72
|
+
height: number
|
|
73
|
+
/** Draws the model's current state (after `native.update`) into a new picture. */
|
|
74
|
+
draw(): SkPicture
|
|
75
|
+
/** For drawing on another worklet runtime with `createDrawer`. */
|
|
76
|
+
textures: SkImage[]
|
|
77
|
+
shaders: SkShader[]
|
|
78
|
+
canvas: CanvasInfo
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// For error messages: URLs lose their query, which can hold tokens.
|
|
82
|
+
const describe = (source: AssetSource) =>
|
|
83
|
+
typeof source === 'string' ? redactUrl(source) : String(source)
|
|
84
|
+
|
|
85
|
+
async function readAsset(source: AssetSource, options: Live2DLoadOptions): Promise<ArrayBuffer> {
|
|
86
|
+
// Remote files are fetched directly: expo-asset can't send headers.
|
|
87
|
+
if (isRemote(source)) return fetchBytes(source, options)
|
|
88
|
+
const asset = Asset.fromModule(source)
|
|
89
|
+
// Android release builds embed images as drawables, and expo-asset marks them
|
|
90
|
+
// downloaded with a bare resource name (enough for <Image>), which isn't a file.
|
|
91
|
+
// Download for real: that copies the resource into the cache.
|
|
92
|
+
if (asset.localUri && !asset.localUri.includes(':')) {
|
|
93
|
+
asset.downloaded = false
|
|
94
|
+
asset.localUri = null
|
|
95
|
+
}
|
|
96
|
+
await asset.downloadAsync()
|
|
97
|
+
if (!asset.localUri) {
|
|
98
|
+
throw new Error(`Live2D: failed to download asset ${describe(source)}`)
|
|
99
|
+
}
|
|
100
|
+
return new File(asset.localUri).arrayBuffer()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function loadTexture(source: AssetSource, options: Live2DLoadOptions): Promise<SkImage> {
|
|
104
|
+
const bytes = new Uint8Array(await readAsset(source, options))
|
|
105
|
+
const image = Skia.Image.MakeImageFromEncoded(Skia.Data.fromBytes(bytes))
|
|
106
|
+
if (!image) {
|
|
107
|
+
throw new Error(`Live2D: failed to decode texture ${describe(source)}`)
|
|
108
|
+
}
|
|
109
|
+
return image
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let nextModelId = 1
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Loads a model from its files, from a `require.context` of its folder, or
|
|
116
|
+
* from the URL of its `model3.json` (the files it lists are fetched next to it).
|
|
117
|
+
* `options` apply to every remote file.
|
|
118
|
+
*/
|
|
119
|
+
export async function loadLive2DModel(
|
|
120
|
+
input: Live2DModelSource | Live2DModelContext | string,
|
|
121
|
+
options: Live2DLoadOptions = {},
|
|
122
|
+
): Promise<Live2DModel> {
|
|
123
|
+
const source =
|
|
124
|
+
typeof input === 'string'
|
|
125
|
+
? await sourceFromUrl(input, options)
|
|
126
|
+
: isModelContext(input)
|
|
127
|
+
? sourceFromContext(input)
|
|
128
|
+
: input
|
|
129
|
+
const [moc, textures] = await Promise.all([
|
|
130
|
+
readAsset(source.moc, options),
|
|
131
|
+
Promise.all(source.textures.map((t) => loadTexture(t, options))),
|
|
132
|
+
])
|
|
133
|
+
const native = Live2dSkia.loadModel(moc, toCubismJson(source.settings))
|
|
134
|
+
if (source.physics) native.loadPhysics(toCubismJson(source.physics))
|
|
135
|
+
if (source.pose) native.loadPose(toCubismJson(source.pose))
|
|
136
|
+
for (const [group, motions] of Object.entries(source.motions ?? {})) {
|
|
137
|
+
motions.forEach((motion, i) => {
|
|
138
|
+
native.loadMotion(group, i, toCubismJson(motion))
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
for (const [name, expression] of Object.entries(source.expressions ?? {})) {
|
|
142
|
+
native.loadExpression(name, toCubismJson(expression))
|
|
143
|
+
}
|
|
144
|
+
// Apply the pose and the first idle motion's starting state.
|
|
145
|
+
native.update(0)
|
|
146
|
+
|
|
147
|
+
// Trilinear: models are usually drawn far smaller than their 2048px textures,
|
|
148
|
+
// and without mipmaps thin lines (hair, outlines) break up into jaggies.
|
|
149
|
+
const shaders = textures.map((t) =>
|
|
150
|
+
t.makeShaderOptions(TileMode.Clamp, TileMode.Clamp, FilterMode.Linear, MipmapMode.Linear),
|
|
151
|
+
)
|
|
152
|
+
const width = native.canvasWidth
|
|
153
|
+
const height = native.canvasHeight
|
|
154
|
+
const canvas: CanvasInfo = {
|
|
155
|
+
width,
|
|
156
|
+
height,
|
|
157
|
+
originX: native.originX,
|
|
158
|
+
originY: native.originY,
|
|
159
|
+
pixelsPerUnit: native.pixelsPerUnit,
|
|
160
|
+
}
|
|
161
|
+
setNativeTextures(native, textures, shaders)
|
|
162
|
+
const nativeDraw = createNativeDrawer(native, canvas)
|
|
163
|
+
const drawer = createDrawer(textures, shaders, canvas)
|
|
164
|
+
const draw = nativeDraw ?? (() => drawer(native.getDrawables()))
|
|
165
|
+
// Model pixels (y-down) to model units (y-up).
|
|
166
|
+
const toUnits = (x: number, y: number) =>
|
|
167
|
+
[
|
|
168
|
+
(x - canvas.originX) / canvas.pixelsPerUnit,
|
|
169
|
+
(canvas.originY - y) / canvas.pixelsPerUnit,
|
|
170
|
+
] as const
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
id: nextModelId++,
|
|
174
|
+
motion: (group, index, priority) => native.startMotion(group, index, priority),
|
|
175
|
+
expression: (name) => native.setExpression(name),
|
|
176
|
+
resetExpression: () => native.clearExpression(),
|
|
177
|
+
setParameter: (id, value) => native.setParameter(id, value),
|
|
178
|
+
resetParameter: (id) => native.clearParameter(id),
|
|
179
|
+
// Relative to the canvas center, -1..1, y-up.
|
|
180
|
+
focus: (x, y) => native.focus((x / width) * 2 - 1, 1 - (y / height) * 2),
|
|
181
|
+
resetFocus: () => native.focus(0, 0),
|
|
182
|
+
hitTest: (x, y) => native.hitTest(...toUnits(x, y)),
|
|
183
|
+
motionGroups: Object.keys(source.motions ?? {}),
|
|
184
|
+
expressionNames: Object.keys(source.expressions ?? {}),
|
|
185
|
+
native,
|
|
186
|
+
picture: draw(),
|
|
187
|
+
width,
|
|
188
|
+
height,
|
|
189
|
+
draw,
|
|
190
|
+
textures,
|
|
191
|
+
shaders,
|
|
192
|
+
canvas,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { CubismJson, Live2DModelSource } from './loadModel'
|
|
2
|
+
|
|
3
|
+
/** A Metro `require.context` of a model's folder; the path must be a string literal for Metro to bundle it. */
|
|
4
|
+
export interface Live2DModelContext {
|
|
5
|
+
keys(): string[]
|
|
6
|
+
(id: string): unknown
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface Model3Json {
|
|
10
|
+
FileReferences?: {
|
|
11
|
+
Moc?: string
|
|
12
|
+
Textures?: string[]
|
|
13
|
+
Physics?: string
|
|
14
|
+
Pose?: string
|
|
15
|
+
Motions?: Record<string, { File: string }[]>
|
|
16
|
+
Expressions?: { Name: string; File: string }[]
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isModelContext(
|
|
21
|
+
source: Live2DModelSource | Live2DModelContext,
|
|
22
|
+
): source is Live2DModelContext {
|
|
23
|
+
return typeof source === 'function'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Resolves `file` relative to the folder of `from`, a context key or a URL. */
|
|
27
|
+
export function resolvePath(from: string, file: string): string {
|
|
28
|
+
const parts = from
|
|
29
|
+
.replace(/[?#].*$/, '')
|
|
30
|
+
.split('/')
|
|
31
|
+
.slice(0, -1)
|
|
32
|
+
for (const part of file.split('/')) {
|
|
33
|
+
if (part === '..') parts.pop()
|
|
34
|
+
else if (part !== '.' && part !== '') parts.push(part)
|
|
35
|
+
}
|
|
36
|
+
return parts.join('/')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function sourceFromContext(context: Live2DModelContext): Live2DModelSource {
|
|
40
|
+
const keys = context.keys()
|
|
41
|
+
const model3Keys = keys.filter((k) => k.endsWith('.model3.json'))
|
|
42
|
+
if (model3Keys.length !== 1) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Live2D: expected one .model3.json in the require.context, found ${
|
|
45
|
+
model3Keys.length === 0 ? 'none' : model3Keys.join(', ')
|
|
46
|
+
}`,
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
const model3Key = model3Keys[0]!
|
|
50
|
+
const settings = context(model3Key) as Model3Json
|
|
51
|
+
const refs = settings.FileReferences ?? {}
|
|
52
|
+
|
|
53
|
+
const load = <T>(file: string): T => {
|
|
54
|
+
const key = resolvePath(model3Key, file)
|
|
55
|
+
if (!keys.includes(key)) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Live2D: ${file} (listed in ${model3Key}) isn't in the require.context. Check its filter.`,
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
return context(key) as T
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!refs.Moc) throw new Error(`Live2D: ${model3Key} has no FileReferences.Moc`)
|
|
64
|
+
return {
|
|
65
|
+
settings: settings as CubismJson,
|
|
66
|
+
moc: load(refs.Moc),
|
|
67
|
+
textures: (refs.Textures ?? []).map((t) => load(t)),
|
|
68
|
+
physics: refs.Physics ? load(refs.Physics) : undefined,
|
|
69
|
+
pose: refs.Pose ? load(refs.Pose) : undefined,
|
|
70
|
+
motions: Object.fromEntries(
|
|
71
|
+
Object.entries(refs.Motions ?? {}).map(([group, motions]) => [
|
|
72
|
+
group,
|
|
73
|
+
motions.map((m) => load<CubismJson>(m.File)),
|
|
74
|
+
]),
|
|
75
|
+
),
|
|
76
|
+
expressions: Object.fromEntries(
|
|
77
|
+
(refs.Expressions ?? []).map((e) => [e.Name, load<CubismJson>(e.File)]),
|
|
78
|
+
),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { fetch } from 'expo/fetch'
|
|
2
|
+
import type { CubismJson, Live2DModelSource } from './loadModel'
|
|
3
|
+
import { type Model3Json, resolvePath } from './modelContext'
|
|
4
|
+
|
|
5
|
+
/** How remote model files are requested. */
|
|
6
|
+
export interface Live2DLoadOptions {
|
|
7
|
+
/** Sent with every request for a remote file, e.g. `{ Authorization: 'Bearer …' }`. */
|
|
8
|
+
headers?: Record<string, string>
|
|
9
|
+
/**
|
|
10
|
+
* Gives the URL to request for a file the `model3.json` lists, e.g. to sign
|
|
11
|
+
* each one through your backend. `url` is resolved next to the `model3.json`,
|
|
12
|
+
* without a query; `file` is the path as listed. Replaces the default, which
|
|
13
|
+
* reuses the `model3.json` URL's query (e.g. a token) for files on its host.
|
|
14
|
+
*/
|
|
15
|
+
resolveUrl?: (url: string, file: string) => string | Promise<string>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ABSOLUTE = /^[a-z][a-z0-9+.-]*:\/\//i
|
|
19
|
+
|
|
20
|
+
export function isRemote(source: unknown): source is string {
|
|
21
|
+
return typeof source === 'string' && /^https?:\/\//i.test(source)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A URL without its query and hash, which can hold tokens: for error messages. */
|
|
25
|
+
export function redactUrl(url: string): string {
|
|
26
|
+
return url.replace(/[?#].*$/, '')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const origin = (url: string) => /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*/i.exec(url)?.[0]
|
|
30
|
+
const queryOf = (url: string) => /\?[^#]*/.exec(url)?.[0] ?? ''
|
|
31
|
+
|
|
32
|
+
async function request(url: string, options: Live2DLoadOptions) {
|
|
33
|
+
let res: Awaited<ReturnType<typeof fetch>>
|
|
34
|
+
try {
|
|
35
|
+
res = await fetch(url, { headers: options.headers })
|
|
36
|
+
} catch (e) {
|
|
37
|
+
const reason = e instanceof Error ? e.message : String(e)
|
|
38
|
+
throw new Error(`Live2D: failed to fetch ${redactUrl(url)} (${reason})`)
|
|
39
|
+
}
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
throw new Error(`Live2D: failed to fetch ${redactUrl(url)} (${res.status})`)
|
|
42
|
+
}
|
|
43
|
+
return res
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function fetchBytes(url: string, options: Live2DLoadOptions): Promise<ArrayBuffer> {
|
|
47
|
+
return (await request(url, options)).arrayBuffer()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function fetchJson<T>(url: string, options: Live2DLoadOptions): Promise<T> {
|
|
51
|
+
return (await (await request(url, options)).json()) as T
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Escapes a model3.json file path for a URL: names can have spaces. Already
|
|
55
|
+
// escaped segments are decoded first, so they aren't escaped twice.
|
|
56
|
+
function encodePath(file: string): string {
|
|
57
|
+
return file
|
|
58
|
+
.split('/')
|
|
59
|
+
.map((part) => {
|
|
60
|
+
if (part === '.' || part === '..') return part
|
|
61
|
+
try {
|
|
62
|
+
return encodeURIComponent(decodeURIComponent(part))
|
|
63
|
+
} catch {
|
|
64
|
+
return encodeURIComponent(part)
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
.join('/')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Fetches a `model3.json` and the JSON files it lists; the moc and textures stay URLs. */
|
|
71
|
+
export async function sourceFromUrl(
|
|
72
|
+
url: string,
|
|
73
|
+
options: Live2DLoadOptions = {},
|
|
74
|
+
): Promise<Live2DModelSource> {
|
|
75
|
+
const settings = await fetchJson<Model3Json>(url, options)
|
|
76
|
+
const refs = settings.FileReferences ?? {}
|
|
77
|
+
if (!refs.Moc) {
|
|
78
|
+
throw new Error(`Live2D: ${redactUrl(url)} has no FileReferences.Moc`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const query = queryOf(url)
|
|
82
|
+
const resolve = async (file: string): Promise<string> => {
|
|
83
|
+
const plain = ABSOLUTE.test(file) ? file : resolvePath(url, encodePath(file))
|
|
84
|
+
if (options.resolveUrl) return options.resolveUrl(plain, file)
|
|
85
|
+
// The model3.json's query (e.g. a signing token) goes to its own host only.
|
|
86
|
+
if (!query || origin(plain) !== origin(url)) return plain
|
|
87
|
+
return plain.includes('?') ? `${plain}&${query.slice(1)}` : plain + query
|
|
88
|
+
}
|
|
89
|
+
const json = async (file: string) => fetchJson<CubismJson>(await resolve(file), options)
|
|
90
|
+
|
|
91
|
+
const motionGroups = Object.entries(refs.Motions ?? {})
|
|
92
|
+
const [moc, textures, physics, pose, motions, expressions] = await Promise.all([
|
|
93
|
+
resolve(refs.Moc),
|
|
94
|
+
Promise.all((refs.Textures ?? []).map(resolve)),
|
|
95
|
+
refs.Physics ? json(refs.Physics) : undefined,
|
|
96
|
+
refs.Pose ? json(refs.Pose) : undefined,
|
|
97
|
+
Promise.all(motionGroups.map(([, list]) => Promise.all(list.map((m) => json(m.File))))),
|
|
98
|
+
Promise.all((refs.Expressions ?? []).map((e) => json(e.File))),
|
|
99
|
+
])
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
settings: settings as CubismJson,
|
|
103
|
+
moc,
|
|
104
|
+
textures,
|
|
105
|
+
physics,
|
|
106
|
+
pose,
|
|
107
|
+
motions: Object.fromEntries(motionGroups.map(([group], i) => [group, motions[i]!])),
|
|
108
|
+
expressions: Object.fromEntries(
|
|
109
|
+
(refs.Expressions ?? []).map((e, i) => [e.Name, expressions[i]!]),
|
|
110
|
+
),
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/native.ts
ADDED