@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.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/Live2dSkia.podspec +159 -0
  3. package/README.md +64 -2
  4. package/android/CMakeLists.txt +113 -0
  5. package/android/build.gradle +223 -0
  6. package/android/fix-prefab.gradle +51 -0
  7. package/android/gradle.properties +5 -0
  8. package/android/src/main/AndroidManifest.xml +2 -0
  9. package/android/src/main/cpp/cpp-adapter.cpp +9 -0
  10. package/android/src/main/java/com/live2dskia/Live2dSkiaPackage.kt +20 -0
  11. package/app.plugin.js +2 -0
  12. package/cli/build/index.js +157 -0
  13. package/cpp/cubism/CubismCompat.hpp +26 -0
  14. package/cpp/cubism/CubismHelpers.hpp +27 -0
  15. package/cpp/cubism/CubismRendererStub.cpp +14 -0
  16. package/cpp/cubism/CubismSetup.cpp +54 -0
  17. package/cpp/cubism/CubismSetup.hpp +9 -0
  18. package/cpp/cubism/HeldParameterUpdater.hpp +25 -0
  19. package/cpp/cubism/Live2DUserModel.cpp +125 -0
  20. package/cpp/cubism/Live2DUserModel.hpp +57 -0
  21. package/cpp/cubism/Live2DUserModelAnimation.cpp +107 -0
  22. package/cpp/drawing/JsiUnwrap.hpp +25 -0
  23. package/cpp/drawing/MeshCache.cpp +75 -0
  24. package/cpp/drawing/MeshCache.hpp +45 -0
  25. package/cpp/drawing/SkiaDrawer.cpp +138 -0
  26. package/cpp/drawing/SkiaDrawer.hpp +42 -0
  27. package/cpp/hybrid/HybridLive2DModel.cpp +174 -0
  28. package/cpp/hybrid/HybridLive2DModel.hpp +64 -0
  29. package/cpp/hybrid/HybridLive2dSkia.cpp +10 -0
  30. package/cpp/hybrid/HybridLive2dSkia.hpp +11 -0
  31. package/ios/Bridge.h +8 -0
  32. package/nitro.json +20 -0
  33. package/nitrogen/generated/.gitattributes +1 -0
  34. package/nitrogen/generated/android/Live2dSkia+autolinking.cmake +82 -0
  35. package/nitrogen/generated/android/Live2dSkia+autolinking.gradle +27 -0
  36. package/nitrogen/generated/android/Live2dSkiaOnLoad.cpp +49 -0
  37. package/nitrogen/generated/android/Live2dSkiaOnLoad.hpp +34 -0
  38. package/nitrogen/generated/android/kotlin/com/margelo/nitro/live2dskia/Live2dSkiaOnLoad.kt +35 -0
  39. package/nitrogen/generated/ios/Live2dSkia+autolinking.rb +62 -0
  40. package/nitrogen/generated/ios/Live2dSkia-Swift-Cxx-Bridge.cpp +17 -0
  41. package/nitrogen/generated/ios/Live2dSkia-Swift-Cxx-Bridge.hpp +27 -0
  42. package/nitrogen/generated/ios/Live2dSkia-Swift-Cxx-Umbrella.hpp +38 -0
  43. package/nitrogen/generated/ios/Live2dSkiaAutolinking.mm +35 -0
  44. package/nitrogen/generated/ios/Live2dSkiaAutolinking.swift +16 -0
  45. package/nitrogen/generated/shared/c++/DrawableData.hpp +116 -0
  46. package/nitrogen/generated/shared/c++/HybridLive2DModelSpec.cpp +39 -0
  47. package/nitrogen/generated/shared/c++/HybridLive2DModelSpec.hpp +86 -0
  48. package/nitrogen/generated/shared/c++/HybridLive2dSkiaSpec.cpp +21 -0
  49. package/nitrogen/generated/shared/c++/HybridLive2dSkiaSpec.hpp +66 -0
  50. package/nitrogen/generated/shared/c++/MotionPriority.hpp +80 -0
  51. package/package.json +146 -4
  52. package/plugin/build/index.d.ts +14 -0
  53. package/plugin/build/index.js +81 -0
  54. package/src/components/Live2D.tsx +131 -0
  55. package/src/components/index.ts +2 -0
  56. package/src/hooks/index.ts +8 -0
  57. package/src/hooks/useLive2DGesture.ts +140 -0
  58. package/src/hooks/useLive2DModel.ts +47 -0
  59. package/src/index.ts +5 -0
  60. package/src/loader/cubismJson.ts +23 -0
  61. package/src/loader/index.ts +9 -0
  62. package/src/loader/loadModel.ts +194 -0
  63. package/src/loader/modelContext.ts +80 -0
  64. package/src/loader/modelUrl.ts +112 -0
  65. package/src/native.ts +4 -0
  66. package/src/rendering/drawer.ts +256 -0
  67. package/src/rendering/skia.ts +114 -0
  68. package/src/rendering/ticker/addToTicker.ts +52 -0
  69. package/src/rendering/ticker/constants.ts +11 -0
  70. package/src/rendering/ticker/frames.ts +35 -0
  71. package/src/rendering/ticker/index.ts +5 -0
  72. package/src/rendering/ticker/loop.ts +115 -0
  73. package/src/rendering/ticker/state.ts +65 -0
  74. package/src/rendering/ticker/stats.ts +36 -0
  75. package/src/rendering/ticker/types.ts +57 -0
  76. package/src/specs/react-native-live2d-skia.nitro.ts +88 -0
  77. 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,5 @@
1
+ export * from './components'
2
+ export * from './hooks'
3
+ export * from './loader'
4
+ export { Live2dSkia } from './native'
5
+ export type { MotionPriority } from './specs/react-native-live2d-skia.nitro'
@@ -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,9 @@
1
+ export type {
2
+ AssetSource,
3
+ CubismJson,
4
+ Live2DModel,
5
+ Live2DModelSource,
6
+ } from './loadModel'
7
+ export { loadLive2DModel } from './loadModel'
8
+ export type { Live2DModelContext } from './modelContext'
9
+ export type { Live2DLoadOptions } from './modelUrl'
@@ -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
@@ -0,0 +1,4 @@
1
+ import { NitroModules } from 'react-native-nitro-modules'
2
+ import type { Live2dSkia as Live2dSkiaSpec } from './specs/react-native-live2d-skia.nitro'
3
+
4
+ export const Live2dSkia = NitroModules.createHybridObject<Live2dSkiaSpec>('Live2dSkia')