@airisu-dev/react-native-live2d-skia 0.0.0-stage → 0.1.1

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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/Live2dSkia.podspec +159 -0
  3. package/README.md +258 -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 +149 -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 +195 -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/decodeImage.ts +12 -0
  67. package/src/rendering/drawer.ts +256 -0
  68. package/src/rendering/skia.ts +26 -0
  69. package/src/rendering/skiaTypes.ts +99 -0
  70. package/src/rendering/ticker/addToTicker.ts +52 -0
  71. package/src/rendering/ticker/constants.ts +11 -0
  72. package/src/rendering/ticker/frames.ts +35 -0
  73. package/src/rendering/ticker/index.ts +5 -0
  74. package/src/rendering/ticker/loop.ts +115 -0
  75. package/src/rendering/ticker/state.ts +65 -0
  76. package/src/rendering/ticker/stats.ts +36 -0
  77. package/src/rendering/ticker/types.ts +57 -0
  78. package/src/specs/react-native-live2d-skia.nitro.ts +88 -0
  79. package/src/utils/clock.ts +10 -0
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ // Expo config plugin: tells the native builds where the Cubism SDK for Native is.
3
+ //
4
+ // "plugins": [["@airisu-dev/react-native-live2d-skia", { "cubismSdkPath": "./vendor/CubismSdkForNative" }]]
5
+ //
6
+ // Cubism Core is proprietary and not shipped: download the SDK with
7
+ // `@airisu-dev/react-native-live2d-skia fetch-sdk`, accepting Live2D's license. The path is relative to the app's root.
8
+ var __importDefault = (this && this.__importDefault) || function (mod) {
9
+ return (mod && mod.__esModule) ? mod : { "default": mod };
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.withLive2dSkia = void 0;
13
+ exports.nativeProperties = nativeProperties;
14
+ exports.skiaPackageOf = skiaPackageOf;
15
+ exports.warnIfMissing = warnIfMissing;
16
+ const config_plugins_1 = require("expo/config-plugins");
17
+ const fs_1 = __importDefault(require("fs"));
18
+ const path_1 = __importDefault(require("path"));
19
+ // Read by android/build.gradle and Live2dSkia.podspec.
20
+ const SDK_PROPERTY = 'live2dSkia.cubismSdkPath';
21
+ const SKIA_PROPERTY = 'live2dSkia.skiaPackage';
22
+ // The Skia packages react-native-live2d-skia can draw with in C++, by preference.
23
+ const SKIA_PACKAGES = ['react-native-skia', '@shopify/react-native-skia'];
24
+ const FETCH_HINT = 'Download it with `@airisu-dev/react-native-live2d-skia fetch-sdk --accept-license` (through npx, bunx, yarn or pnpm).';
25
+ const pkg = JSON.parse(fs_1.default.readFileSync(path_1.default.join(__dirname, '..', '..', 'package.json'), 'utf8'));
26
+ // Paths are written relative to the native folder, so a prebuilt project
27
+ // still builds after it's moved or checked out somewhere else.
28
+ const relativeTo = (dir, target) => path_1.default.relative(dir, target).split(path_1.default.sep).join('/');
29
+ function nativeProperties(projectRoot, platformProjectRoot, sdkPath) {
30
+ const sdkDir = path_1.default.resolve(projectRoot, sdkPath);
31
+ return {
32
+ [SDK_PROPERTY]: relativeTo(platformProjectRoot, sdkDir),
33
+ [SKIA_PROPERTY]: skiaPackageOf(projectRoot),
34
+ };
35
+ }
36
+ const withLive2dSkia = (config, props) => {
37
+ const sdkPath = props?.cubismSdkPath;
38
+ if (!sdkPath) {
39
+ throw new Error('[react-native-live2d-skia] Set the Cubism SDK for Native folder: ' +
40
+ '["@airisu-dev/react-native-live2d-skia", { "cubismSdkPath": "./vendor/CubismSdkForNative" }]. ' +
41
+ FETCH_HINT);
42
+ }
43
+ config = (0, config_plugins_1.withGradleProperties)(config, (c) => {
44
+ const { projectRoot, platformProjectRoot } = c.modRequest;
45
+ warnIfMissing(path_1.default.resolve(projectRoot, sdkPath));
46
+ const values = nativeProperties(projectRoot, platformProjectRoot, sdkPath);
47
+ c.modResults = c.modResults.filter((item) => !(item.type === 'property' && item.key in values));
48
+ for (const [key, value] of Object.entries(values)) {
49
+ c.modResults.push({ type: 'property', key, value });
50
+ }
51
+ return c;
52
+ });
53
+ config = (0, config_plugins_1.withPodfileProperties)(config, (c) => {
54
+ const { projectRoot, platformProjectRoot } = c.modRequest;
55
+ Object.assign(c.modResults, nativeProperties(projectRoot, platformProjectRoot, sdkPath));
56
+ return c;
57
+ });
58
+ return config;
59
+ };
60
+ exports.withLive2dSkia = withLive2dSkia;
61
+ // The Skia package the app depends on, by its own package.json: in a monorepo
62
+ // both can be installed, but an app uses one. Empty when it has neither: the
63
+ // native build then leaves out the C++ drawer.
64
+ function skiaPackageOf(projectRoot) {
65
+ const appPackage = JSON.parse(fs_1.default.readFileSync(path_1.default.join(projectRoot, 'package.json'), 'utf8'));
66
+ const found = SKIA_PACKAGES.filter((name) => appPackage.dependencies?.[name]);
67
+ if (found.length > 1) {
68
+ console.warn(`[react-native-live2d-skia] The app depends on both ${found.join(' and ')}: drawing with ${found[0]}. Skia says not to install both.`);
69
+ }
70
+ if (found.length === 0) {
71
+ console.warn(`[react-native-live2d-skia] The app depends on neither ${SKIA_PACKAGES.join(' nor ')}. Install one: react-native-live2d-skia draws with Skia.`);
72
+ }
73
+ return found[0] ?? '';
74
+ }
75
+ // Not an error: CI may fetch the SDK after prebuild. The native build checks again.
76
+ function warnIfMissing(sdkDir) {
77
+ if (!fs_1.default.existsSync(path_1.default.join(sdkDir, 'cubism-info.yml'))) {
78
+ console.warn(`[react-native-live2d-skia] Cubism SDK for Native not found at ${sdkDir}. ${FETCH_HINT}`);
79
+ }
80
+ }
81
+ exports.default = (0, config_plugins_1.createRunOncePlugin)(exports.withLive2dSkia, pkg.name, pkg.version);
@@ -0,0 +1,131 @@
1
+ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
2
+ import { useFrameCallback, useSharedValue } from 'react-native-reanimated'
3
+ import {
4
+ createSynchronizable,
5
+ createWorkletRuntime,
6
+ scheduleOnRuntime,
7
+ type WorkletRuntime,
8
+ } from 'react-native-worklets'
9
+ import type { Live2DModel } from '../loader/loadModel'
10
+ import { Group, Picture, Skia, type SkPicture } from '../rendering/skia'
11
+ import { addToTicker, type Live2DFrameStats, type PublishedFrame } from '../rendering/ticker'
12
+
13
+ export type { Live2DFrameStats } from '../rendering/ticker'
14
+
15
+ export interface Live2DProps {
16
+ model: Live2DModel | null
17
+ /** Top-left position on the canvas. */
18
+ x?: number
19
+ y?: number
20
+ /** Uniform scale of the model's native canvas size. */
21
+ scale?: number
22
+ /** Stops animating and keeps the current frame. */
23
+ paused?: boolean
24
+ /** Caps the frame rate, e.g. 30 to save battery. Default: the display's refresh rate. */
25
+ maxFps?: number
26
+ /** Called about once a second with timings of the animation loop. */
27
+ onFrameStats?: (stats: Live2DFrameStats) => void
28
+ }
29
+
30
+ const DISPLAY_SAMPLE_FRAMES = 60
31
+
32
+ // Shown until the model loads. A recorder must begin recording before it can finish.
33
+ const emptyRecorder = Skia.PictureRecorder()
34
+ emptyRecorder.beginRecording(Skia.XYWHRect(0, 0, 0, 0))
35
+ const EMPTY_PICTURE = emptyRecorder.finishRecordingAsPicture()
36
+
37
+ // Models animate on a background thread so a busy JS thread doesn't drop frames:
38
+ // one runtime and one loop (rendering/ticker) for every <Live2D>.
39
+ let runtime: WorkletRuntime | null = null
40
+ const live2dRuntime = () => (runtime ??= createWorkletRuntime({ name: 'live2d' }))
41
+
42
+ export function Live2D({
43
+ model,
44
+ x = 0,
45
+ y = 0,
46
+ scale = 1,
47
+ paused = false,
48
+ maxFps,
49
+ onFrameStats,
50
+ }: Live2DProps) {
51
+ // A shared value lets Skia redraw each frame without a React re-render.
52
+ const picture = useSharedValue<SkPicture>(model?.picture ?? EMPTY_PICTURE)
53
+ // Reset to the load-time frame only when the model changes, so pausing keeps the current frame.
54
+ const shownModel = useRef<Live2DModel | null>(null)
55
+ // The loop publishes frames here. scheduleOnUI can't be called from a custom
56
+ // worklet runtime (outside Bundle Mode), so the UI thread polls instead.
57
+ const [latest] = useState(() => createSynchronizable<PublishedFrame>(null))
58
+ // The frame shown, told back to the loop so it frees only frames no longer shown.
59
+ const [shown] = useState(() => createSynchronizable(-1))
60
+ // The display's frame interval: the shortest over a window of UI frames, so
61
+ // dropped frames don't count. 0 until measured.
62
+ const [displayFrameMs] = useState(() => createSynchronizable(0))
63
+ const sampleMin = useSharedValue(Infinity)
64
+ const sampleCount = useSharedValue(0)
65
+ useFrameCallback(({ timeSincePreviousFrame }) => {
66
+ const next = latest.getDirty()
67
+ if (next && next.frame !== shown.getDirty()) {
68
+ picture.value = next.picture
69
+ shown.setBlocking(next.frame)
70
+ }
71
+ if (timeSincePreviousFrame !== null && timeSincePreviousFrame > 0) {
72
+ sampleMin.value = Math.min(sampleMin.value, timeSincePreviousFrame)
73
+ if (++sampleCount.value >= DISPLAY_SAMPLE_FRAMES) {
74
+ displayFrameMs.setBlocking(sampleMin.value)
75
+ sampleMin.value = Infinity
76
+ sampleCount.value = 0
77
+ }
78
+ }
79
+ })
80
+
81
+ // Stable for the loop to call; updated after render, as refs mustn't be written while rendering.
82
+ const onFrameStatsRef = useRef(onFrameStats)
83
+ useLayoutEffect(() => {
84
+ onFrameStatsRef.current = onFrameStats
85
+ })
86
+ const reportStats = useCallback((stats: Live2DFrameStats) => onFrameStatsRef.current?.(stats), [])
87
+ const wantStats = onFrameStats !== undefined
88
+ const capFrameMs = maxFps ? 1000 / maxFps : 0
89
+
90
+ useEffect(() => {
91
+ if (!model) return
92
+ if (shownModel.current !== model) {
93
+ shownModel.current = model
94
+ // set(), not `.value =`, outside worklets: the React Compiler form.
95
+ picture.set(model.picture)
96
+ }
97
+ if (paused) return
98
+
99
+ // Frame numbers restart when the model starts animating again.
100
+ latest.setBlocking(null)
101
+ shown.setBlocking(-1)
102
+ const running = createSynchronizable(true)
103
+ scheduleOnRuntime(
104
+ live2dRuntime(),
105
+ addToTicker,
106
+ {
107
+ id: model.id,
108
+ native: model.native,
109
+ textures: model.textures,
110
+ shaders: model.shaders,
111
+ canvas: model.canvas,
112
+ },
113
+ {
114
+ target: latest,
115
+ shown,
116
+ running,
117
+ displayMs: displayFrameMs,
118
+ capMs: capFrameMs,
119
+ report: wantStats ? reportStats : null,
120
+ },
121
+ )
122
+ return () => running.setBlocking(false)
123
+ }, [model, paused, picture, latest, shown, displayFrameMs, capFrameMs, wantStats, reportStats])
124
+
125
+ if (!model) return null
126
+ return (
127
+ <Group transform={[{ translateX: x }, { translateY: y }, { scale }]}>
128
+ <Picture picture={picture} />
129
+ </Group>
130
+ )
131
+ }
@@ -0,0 +1,2 @@
1
+ export type { Live2DFrameStats, Live2DProps } from './Live2D'
2
+ export { Live2D } from './Live2D'
@@ -0,0 +1,8 @@
1
+ export type {
2
+ Live2DGestureOptions,
3
+ Live2DGesturesOptions,
4
+ Live2DGestureTarget,
5
+ } from './useLive2DGesture'
6
+ export { useLive2DGesture, useLive2DGestures } from './useLive2DGesture'
7
+ export type { Live2DModelState } from './useLive2DModel'
8
+ export { useLive2DModel } from './useLive2DModel'
@@ -0,0 +1,140 @@
1
+ import { useLayoutEffect, useMemo, useRef } from 'react'
2
+ import { Gesture } from 'react-native-gesture-handler'
3
+ import type { Live2DModel } from '../loader/loadModel'
4
+
5
+ export interface Live2DGestureTarget {
6
+ model: Live2DModel | null
7
+ x?: number
8
+ y?: number
9
+ scale?: number
10
+ /**
11
+ * Called with the hit areas under a tap. Return true to skip the default
12
+ * reaction: a `Tap<area>` motion, else a random expression for `Head`, else a `Tap` motion.
13
+ */
14
+ onHit?: (hitAreas: string[]) => boolean | void
15
+ /** Makes the model draggable by long-press. Called with its new top-left; the app moves it. */
16
+ onMove?: (x: number, y: number) => void
17
+ }
18
+
19
+ export interface Live2DGesturesOptions {
20
+ /** Models follow the finger while it's down. Default true. */
21
+ focus?: boolean
22
+ }
23
+
24
+ export interface Live2DGestureOptions
25
+ extends Omit<Live2DGestureTarget, 'model'>,
26
+ Live2DGesturesOptions {}
27
+
28
+ function react(model: Live2DModel, hitAreas: string[]) {
29
+ for (const area of hitAreas) {
30
+ const group = `Tap${area}`
31
+ if (model.motionGroups.includes(group)) {
32
+ model.motion(group)
33
+ return
34
+ }
35
+ if (area === 'Head' && model.expressionNames.length > 0) {
36
+ model.expression()
37
+ return
38
+ }
39
+ }
40
+ // Some models have one `Tap` group for every area.
41
+ if (model.motionGroups.includes('Tap')) model.motion('Tap')
42
+ }
43
+
44
+ const LONG_PRESS_MS = 350
45
+
46
+ function toModel({ x = 0, y = 0, scale = 1 }: Live2DGestureTarget, px: number, py: number) {
47
+ return [(px - x) / scale, (py - y) / scale] as const
48
+ }
49
+
50
+ function topmostHit(targets: Live2DGestureTarget[], px: number, py: number) {
51
+ // Later targets are drawn over earlier ones.
52
+ for (let i = targets.length - 1; i >= 0; i--) {
53
+ const t = targets[i]!
54
+ if (!t.model) continue
55
+ const hitAreas = t.model.hitTest(...toModel(t, px, py))
56
+ if (hitAreas.length > 0) return { target: t, hitAreas }
57
+ }
58
+ return null
59
+ }
60
+
61
+ /**
62
+ * Touch interaction for several `<Live2D>` models in one canvas, for a
63
+ * `<GestureDetector>` around the `<Canvas>`. List targets in drawing order.
64
+ */
65
+ export function useLive2DGestures(
66
+ targets: Live2DGestureTarget[],
67
+ { focus = true }: Live2DGesturesOptions = {},
68
+ ) {
69
+ // The React Compiler miscompiled these callbacks (a `let` shared between them).
70
+ 'use no memo'
71
+
72
+ // Read at event time, so the gesture is built once. Updated after render.
73
+ const latest = useRef(targets)
74
+ useLayoutEffect(() => {
75
+ latest.current = targets
76
+ })
77
+
78
+ // The callbacks only run on touch events; the refs rule can't tell through RNGH's builder API.
79
+ /* eslint-disable react-hooks/refs */
80
+ return useMemo(() => {
81
+ const focusAll = (px: number, py: number) => {
82
+ for (const t of latest.current) t.model?.focus(...toModel(t, px, py))
83
+ }
84
+
85
+ const pan = Gesture.Pan()
86
+ .runOnJS(true)
87
+ .enabled(focus)
88
+ .onBegin((e) => focusAll(e.x, e.y))
89
+ .onUpdate((e) => focusAll(e.x, e.y))
90
+ .onFinalize(() => {
91
+ for (const t of latest.current) t.model?.resetFocus()
92
+ })
93
+
94
+ // Shorter than a long press, so picking a model up isn't also a tap.
95
+ const tap = Gesture.Tap()
96
+ .runOnJS(true)
97
+ .maxDuration(LONG_PRESS_MS - 50)
98
+ .onEnd((e, success) => {
99
+ if (!success) return
100
+ const hit = topmostHit(latest.current, e.x, e.y)
101
+ if (hit && hit.target.onHit?.(hit.hitAreas) !== true) {
102
+ react(hit.target.model!, hit.hitAreas)
103
+ }
104
+ })
105
+
106
+ // The dragged model (found again by model, as targets can change) and the finger's
107
+ // offset from its top-left. On an object so it survives recompilation.
108
+ const drag: {
109
+ current: { model: Live2DModel; dx: number; dy: number } | null
110
+ } = { current: null }
111
+ const dragPan = Gesture.Pan()
112
+ .runOnJS(true)
113
+ .activateAfterLongPress(LONG_PRESS_MS)
114
+ .onStart((e) => {
115
+ const hit = topmostHit(latest.current, e.x, e.y)
116
+ if (!hit?.target.onMove) return
117
+ const { model, x = 0, y = 0 } = hit.target
118
+ drag.current = { model: model!, dx: e.x - x, dy: e.y - y }
119
+ })
120
+ .onUpdate((e) => {
121
+ if (!drag.current) return
122
+ const { model, dx, dy } = drag.current
123
+ latest.current.find((t) => t.model === model)?.onMove?.(e.x - dx, e.y - dy)
124
+ })
125
+ .onFinalize(() => {
126
+ drag.current = null
127
+ })
128
+
129
+ return Gesture.Simultaneous(pan, tap, dragPan)
130
+ }, [focus])
131
+ /* eslint-enable react-hooks/refs */
132
+ }
133
+
134
+ /** Touch interaction for one `<Live2D>`. For several models in one canvas, use `useLive2DGestures`. */
135
+ export function useLive2DGesture(
136
+ model: Live2DModel | null,
137
+ { focus, ...target }: Live2DGestureOptions = {},
138
+ ) {
139
+ return useLive2DGestures([{ model, ...target }], { focus })
140
+ }
@@ -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,195 @@
1
+ import { Asset } from 'expo-asset'
2
+ import { File } from 'expo-file-system'
3
+ import { Live2dSkia } from '../native'
4
+ import { decodeImage } from '../rendering/decodeImage'
5
+ import {
6
+ type CanvasInfo,
7
+ createDrawer,
8
+ createNativeDrawer,
9
+ setNativeTextures,
10
+ } from '../rendering/drawer'
11
+ import {
12
+ FilterMode,
13
+ MipmapMode,
14
+ type SkImage,
15
+ Skia,
16
+ type SkPicture,
17
+ type SkShader,
18
+ TileMode,
19
+ } from '../rendering/skia'
20
+ import type {
21
+ MotionPriority,
22
+ Live2DModel as NativeModel,
23
+ } from '../specs/react-native-live2d-skia.nitro'
24
+ import { toCubismJson } from './cubismJson'
25
+ import { isModelContext, type Live2DModelContext, sourceFromContext } from './modelContext'
26
+ import { fetchBytes, isRemote, type Live2DLoadOptions, redactUrl, sourceFromUrl } from './modelUrl'
27
+
28
+ export type AssetSource = number | string
29
+
30
+ /** A parsed Cubism JSON file, e.g. `require('../model.physics3.json')`. */
31
+ export type CubismJson = object
32
+
33
+ export interface Live2DModelSource {
34
+ /** Parsed `model3.json`. */
35
+ settings: CubismJson
36
+ moc: AssetSource
37
+ /** In the order of `FileReferences.Textures`. */
38
+ textures: AssetSource[]
39
+ physics?: CubismJson
40
+ pose?: CubismJson
41
+ /** By group, in the order of `FileReferences.Motions`. A random `Idle` motion plays when nothing else does. */
42
+ motions?: Record<string, CubismJson[]>
43
+ expressions?: Record<string, CubismJson>
44
+ }
45
+
46
+ /** A loaded model. Points are in model pixels: native canvas size, origin top-left, like `picture`. */
47
+ export interface Live2DModel {
48
+ /**
49
+ * Plays a motion of `group`, a random one without `index`. Returns false
50
+ * if there's no such motion or one with a higher priority is playing.
51
+ */
52
+ motion(group: string, index?: number, priority?: MotionPriority): boolean
53
+ /** Fades to an expression, a random one without `name`. Returns false if there's none. */
54
+ expression(name?: string): boolean
55
+ resetExpression(): void
56
+ /** Holds a parameter at `value`, over motions, until `resetParameter`. */
57
+ setParameter(id: string, value: number): void
58
+ resetParameter(id: string): void
59
+ focus(x: number, y: number): void
60
+ resetFocus(): void
61
+ /** Names of the `model3.json` hit areas at a point, e.g. `['Head', 'Body']`. */
62
+ hitTest(x: number, y: number): string[]
63
+ motionGroups: string[]
64
+ expressionNames: string[]
65
+
66
+ /** Unique per loaded model: <Live2D>s showing the same model share its frames. */
67
+ id: number
68
+ /** Native model. Thread-safe. */
69
+ native: NativeModel
70
+ /** The model drawn at load, at its native canvas size, origin top-left. */
71
+ picture: SkPicture
72
+ width: number
73
+ height: number
74
+ /** Draws the model's current state (after `native.update`) into a new picture. */
75
+ draw(): SkPicture
76
+ /** For drawing on another worklet runtime with `createDrawer`. */
77
+ textures: SkImage[]
78
+ shaders: SkShader[]
79
+ canvas: CanvasInfo
80
+ }
81
+
82
+ // For error messages: URLs lose their query, which can hold tokens.
83
+ const describe = (source: AssetSource) =>
84
+ typeof source === 'string' ? redactUrl(source) : String(source)
85
+
86
+ async function readAsset(source: AssetSource, options: Live2DLoadOptions): Promise<ArrayBuffer> {
87
+ // Remote files are fetched directly: expo-asset can't send headers.
88
+ if (isRemote(source)) return fetchBytes(source, options)
89
+ const asset = Asset.fromModule(source)
90
+ // Android release builds embed images as drawables, and expo-asset marks them
91
+ // downloaded with a bare resource name (enough for <Image>), which isn't a file.
92
+ // Download for real: that copies the resource into the cache.
93
+ if (asset.localUri && !asset.localUri.includes(':')) {
94
+ asset.downloaded = false
95
+ asset.localUri = null
96
+ }
97
+ await asset.downloadAsync()
98
+ if (!asset.localUri) {
99
+ throw new Error(`Live2D: failed to download asset ${describe(source)}`)
100
+ }
101
+ return new File(asset.localUri).arrayBuffer()
102
+ }
103
+
104
+ async function loadTexture(source: AssetSource, options: Live2DLoadOptions): Promise<SkImage> {
105
+ const bytes = new Uint8Array(await readAsset(source, options))
106
+ const image = decodeImage(Skia.Image, Skia.Data.fromBytes(bytes))
107
+ if (!image) {
108
+ throw new Error(`Live2D: failed to decode texture ${describe(source)}`)
109
+ }
110
+ return image
111
+ }
112
+
113
+ let nextModelId = 1
114
+
115
+ /**
116
+ * Loads a model from its files, from a `require.context` of its folder, or
117
+ * from the URL of its `model3.json` (the files it lists are fetched next to it).
118
+ * `options` apply to every remote file.
119
+ */
120
+ export async function loadLive2DModel(
121
+ input: Live2DModelSource | Live2DModelContext | string,
122
+ options: Live2DLoadOptions = {},
123
+ ): Promise<Live2DModel> {
124
+ const source =
125
+ typeof input === 'string'
126
+ ? await sourceFromUrl(input, options)
127
+ : isModelContext(input)
128
+ ? sourceFromContext(input)
129
+ : input
130
+ const [moc, textures] = await Promise.all([
131
+ readAsset(source.moc, options),
132
+ Promise.all(source.textures.map((t) => loadTexture(t, options))),
133
+ ])
134
+ const native = Live2dSkia.loadModel(moc, toCubismJson(source.settings))
135
+ if (source.physics) native.loadPhysics(toCubismJson(source.physics))
136
+ if (source.pose) native.loadPose(toCubismJson(source.pose))
137
+ for (const [group, motions] of Object.entries(source.motions ?? {})) {
138
+ motions.forEach((motion, i) => {
139
+ native.loadMotion(group, i, toCubismJson(motion))
140
+ })
141
+ }
142
+ for (const [name, expression] of Object.entries(source.expressions ?? {})) {
143
+ native.loadExpression(name, toCubismJson(expression))
144
+ }
145
+ // Apply the pose and the first idle motion's starting state.
146
+ native.update(0)
147
+
148
+ // Trilinear: models are usually drawn far smaller than their 2048px textures,
149
+ // and without mipmaps thin lines (hair, outlines) break up into jaggies.
150
+ const shaders = textures.map((t) =>
151
+ t.makeShaderOptions(TileMode.Clamp, TileMode.Clamp, FilterMode.Linear, MipmapMode.Linear),
152
+ )
153
+ const width = native.canvasWidth
154
+ const height = native.canvasHeight
155
+ const canvas: CanvasInfo = {
156
+ width,
157
+ height,
158
+ originX: native.originX,
159
+ originY: native.originY,
160
+ pixelsPerUnit: native.pixelsPerUnit,
161
+ }
162
+ setNativeTextures(native, textures, shaders)
163
+ const nativeDraw = createNativeDrawer(native, canvas)
164
+ const drawer = createDrawer(textures, shaders, canvas)
165
+ const draw = nativeDraw ?? (() => drawer(native.getDrawables()))
166
+ // Model pixels (y-down) to model units (y-up).
167
+ const toUnits = (x: number, y: number) =>
168
+ [
169
+ (x - canvas.originX) / canvas.pixelsPerUnit,
170
+ (canvas.originY - y) / canvas.pixelsPerUnit,
171
+ ] as const
172
+
173
+ return {
174
+ id: nextModelId++,
175
+ motion: (group, index, priority) => native.startMotion(group, index, priority),
176
+ expression: (name) => native.setExpression(name),
177
+ resetExpression: () => native.clearExpression(),
178
+ setParameter: (id, value) => native.setParameter(id, value),
179
+ resetParameter: (id) => native.clearParameter(id),
180
+ // Relative to the canvas center, -1..1, y-up.
181
+ focus: (x, y) => native.focus((x / width) * 2 - 1, 1 - (y / height) * 2),
182
+ resetFocus: () => native.focus(0, 0),
183
+ hitTest: (x, y) => native.hitTest(...toUnits(x, y)),
184
+ motionGroups: Object.keys(source.motions ?? {}),
185
+ expressionNames: Object.keys(source.expressions ?? {}),
186
+ native,
187
+ picture: draw(),
188
+ width,
189
+ height,
190
+ draw,
191
+ textures,
192
+ shaders,
193
+ canvas,
194
+ }
195
+ }