@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,66 @@
1
+ ///
2
+ /// HybridLive2dSkiaSpec.hpp
3
+ /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
4
+ /// https://github.com/mrousavy/nitro
5
+ /// Copyright © Marc Rousavy @ Margelo
6
+ ///
7
+
8
+ #pragma once
9
+
10
+ #if __has_include(<NitroModules/HybridObject.hpp>)
11
+ #include <NitroModules/HybridObject.hpp>
12
+ #else
13
+ #error NitroModules cannot be found! Are you sure you installed NitroModules properly?
14
+ #endif
15
+
16
+ // Forward declaration of `HybridLive2DModelSpec` to properly resolve imports.
17
+ namespace margelo::nitro::live2dskia { class HybridLive2DModelSpec; }
18
+
19
+ #include <memory>
20
+ #include "HybridLive2DModelSpec.hpp"
21
+ #include <NitroModules/ArrayBuffer.hpp>
22
+ #include <string>
23
+
24
+ namespace margelo::nitro::live2dskia {
25
+
26
+ using namespace margelo::nitro;
27
+
28
+ /**
29
+ * An abstract base class for `Live2dSkia`
30
+ * Inherit this class to create instances of `HybridLive2dSkiaSpec` in C++.
31
+ * You must explicitly call `HybridObject`'s constructor yourself, because it is virtual.
32
+ * @example
33
+ * ```cpp
34
+ * class HybridLive2dSkia: public HybridLive2dSkiaSpec {
35
+ * public:
36
+ * HybridLive2dSkia(...): HybridObject(TAG) { ... }
37
+ * // ...
38
+ * };
39
+ * ```
40
+ */
41
+ class HybridLive2dSkiaSpec: public virtual HybridObject {
42
+ public:
43
+ // Constructor
44
+ explicit HybridLive2dSkiaSpec(): HybridObject(TAG) { }
45
+
46
+ // Destructor
47
+ ~HybridLive2dSkiaSpec() override = default;
48
+
49
+ public:
50
+ // Properties
51
+
52
+
53
+ public:
54
+ // Methods
55
+ virtual std::shared_ptr<HybridLive2DModelSpec> loadModel(const std::shared_ptr<ArrayBuffer>& moc, const std::string& settingsJson) = 0;
56
+
57
+ protected:
58
+ // Hybrid Setup
59
+ void loadHybridMethods() override;
60
+
61
+ protected:
62
+ // Tag for logging
63
+ static constexpr auto TAG = "Live2dSkia";
64
+ };
65
+
66
+ } // namespace margelo::nitro::live2dskia
@@ -0,0 +1,80 @@
1
+ ///
2
+ /// MotionPriority.hpp
3
+ /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
4
+ /// https://github.com/mrousavy/nitro
5
+ /// Copyright © Marc Rousavy @ Margelo
6
+ ///
7
+
8
+ #pragma once
9
+
10
+ #if __has_include(<NitroModules/NitroHash.hpp>)
11
+ #include <NitroModules/NitroHash.hpp>
12
+ #else
13
+ #error NitroModules cannot be found! Are you sure you installed NitroModules properly?
14
+ #endif
15
+ #if __has_include(<NitroModules/JSIConverter.hpp>)
16
+ #include <NitroModules/JSIConverter.hpp>
17
+ #else
18
+ #error NitroModules cannot be found! Are you sure you installed NitroModules properly?
19
+ #endif
20
+ #if __has_include(<NitroModules/NitroDefines.hpp>)
21
+ #include <NitroModules/NitroDefines.hpp>
22
+ #else
23
+ #error NitroModules cannot be found! Are you sure you installed NitroModules properly?
24
+ #endif
25
+
26
+ namespace margelo::nitro::live2dskia {
27
+
28
+ /**
29
+ * An enum which can be represented as a JavaScript union (MotionPriority).
30
+ */
31
+ enum class MotionPriority {
32
+ IDLE SWIFT_NAME(idle) = 0,
33
+ NORMAL SWIFT_NAME(normal) = 1,
34
+ FORCE SWIFT_NAME(force) = 2,
35
+ } CLOSED_ENUM;
36
+
37
+ } // namespace margelo::nitro::live2dskia
38
+
39
+ namespace margelo::nitro {
40
+
41
+ // C++ MotionPriority <> JS MotionPriority (union)
42
+ template <>
43
+ struct JSIConverter<margelo::nitro::live2dskia::MotionPriority> final {
44
+ static inline margelo::nitro::live2dskia::MotionPriority fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) {
45
+ std::string unionValue = JSIConverter<std::string>::fromJSI(runtime, arg);
46
+ switch (hashString(unionValue.c_str(), unionValue.size())) {
47
+ case hashString("idle"): return margelo::nitro::live2dskia::MotionPriority::IDLE;
48
+ case hashString("normal"): return margelo::nitro::live2dskia::MotionPriority::NORMAL;
49
+ case hashString("force"): return margelo::nitro::live2dskia::MotionPriority::FORCE;
50
+ default: [[unlikely]]
51
+ throw std::invalid_argument("Cannot convert \"" + unionValue + "\" to enum MotionPriority - invalid value!");
52
+ }
53
+ }
54
+ static inline jsi::Value toJSI(jsi::Runtime& runtime, margelo::nitro::live2dskia::MotionPriority arg) {
55
+ switch (arg) {
56
+ case margelo::nitro::live2dskia::MotionPriority::IDLE: return JSIConverter<std::string>::toJSI(runtime, "idle");
57
+ case margelo::nitro::live2dskia::MotionPriority::NORMAL: return JSIConverter<std::string>::toJSI(runtime, "normal");
58
+ case margelo::nitro::live2dskia::MotionPriority::FORCE: return JSIConverter<std::string>::toJSI(runtime, "force");
59
+ default: [[unlikely]]
60
+ throw std::invalid_argument("Cannot convert MotionPriority to JS - invalid value: "
61
+ + std::to_string(static_cast<int>(arg)) + "!");
62
+ }
63
+ }
64
+ static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) {
65
+ if (!value.isString()) {
66
+ return false;
67
+ }
68
+ std::string unionValue = JSIConverter<std::string>::fromJSI(runtime, value);
69
+ switch (hashString(unionValue.c_str(), unionValue.size())) {
70
+ case hashString("idle"):
71
+ case hashString("normal"):
72
+ case hashString("force"):
73
+ return true;
74
+ default:
75
+ return false;
76
+ }
77
+ }
78
+ };
79
+
80
+ } // namespace margelo::nitro
package/package.json CHANGED
@@ -1,6 +1,148 @@
1
1
  {
2
2
  "name": "@airisu-dev/react-native-live2d-skia",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "react-native-live2d-skia is a react native package built with Nitro",
5
+ "main": "src/index",
6
+ "types": "src/index",
7
+ "react-native": "src/index",
8
+ "source": "src/index",
9
+ "bin": {
10
+ "react-native-live2d-skia": "cli/build/index.js"
11
+ },
12
+ "scripts": {
13
+ "typecheck": "tsc --noEmit && tsc --noEmit -p plugin && tsc --noEmit -p cli",
14
+ "test": "bun test",
15
+ "verify:package": "bun scripts/verifyPackage.ts",
16
+ "clean": "git clean -dfX",
17
+ "build": "bun run typecheck && bob build && tsc -b plugin/tsconfig.build.json cli/tsconfig.build.json",
18
+ "codegen": "nitrogen --logLevel=debug",
19
+ "build:node": "tsc -b plugin/tsconfig.build.json cli/tsconfig.build.json",
20
+ "prepack": "tsc -b plugin/tsconfig.build.json cli/tsconfig.build.json"
21
+ },
22
+ "keywords": [
23
+ "react-native",
24
+ "react-native-live2d-skia"
25
+ ],
26
+ "files": [
27
+ "src",
28
+ "!**/*.test.ts",
29
+ "react-native.config.js",
30
+ "lib",
31
+ "nitrogen",
32
+ "cpp",
33
+ "nitro.json",
34
+ "android/build.gradle",
35
+ "android/fix-prefab.gradle",
36
+ "android/gradle.properties",
37
+ "android/CMakeLists.txt",
38
+ "android/src",
39
+ "ios/**/*.h",
40
+ "ios/**/*.m",
41
+ "ios/**/*.mm",
42
+ "ios/**/*.cpp",
43
+ "ios/**/*.swift",
44
+ "app.plugin.js",
45
+ "plugin/build",
46
+ "cli/build",
47
+ "*.podspec",
48
+ "README.md"
49
+ ],
50
+ "repository": "https://github.com/airisu-dev/react-native-live2d-skia.git",
51
+ "author": "airisu-dev",
52
+ "license": "MIT",
53
+ "bugs": "https://github.com/airisu-dev/react-native-live2d-skia/issues",
54
+ "homepage": "https://github.com/airisu-dev/react-native-live2d-skia#readme",
55
+ "publishConfig": {
56
+ "access": "public",
57
+ "registry": "https://registry.npmjs.org/"
58
+ },
59
+ "devDependencies": {
60
+ "@types/bun": "^1.4.4",
61
+ "@types/node": "26.6.5",
62
+ "@types/react": "19.2.0",
63
+ "nitrogen": "^0.37.1",
64
+ "react": "19.2.3",
65
+ "react-native": "0.86.3",
66
+ "react-native-builder-bob": "^0.40.18",
67
+ "react-native-nitro-modules": "^0.37.1",
68
+ "typescript": "^6.0.3"
69
+ },
70
+ "peerDependencies": {
71
+ "expo": "*",
72
+ "expo-asset": "*",
73
+ "expo-file-system": "*",
74
+ "react": "*",
75
+ "react-native": "*",
76
+ "react-native-gesture-handler": "*",
77
+ "react-native-nitro-modules": "*",
78
+ "react-native-reanimated": "*",
79
+ "react-native-skia": ">=3.3.0",
80
+ "@shopify/react-native-skia": ">=2.10.0",
81
+ "react-native-worklets": "*"
82
+ },
83
+ "peerDependenciesMeta": {
84
+ "@shopify/react-native-skia": {
85
+ "optional": true
86
+ },
87
+ "react-native-skia": {
88
+ "optional": true
89
+ }
90
+ },
91
+ "eslintConfig": {
92
+ "root": true,
93
+ "extends": [
94
+ "@react-native",
95
+ "prettier"
96
+ ],
97
+ "plugins": [
98
+ "prettier"
99
+ ],
100
+ "rules": {
101
+ "prettier/prettier": [
102
+ "warn",
103
+ {
104
+ "quoteProps": "consistent",
105
+ "singleQuote": true,
106
+ "tabWidth": 2,
107
+ "trailingComma": "es5",
108
+ "useTabs": false
109
+ }
110
+ ]
111
+ }
112
+ },
113
+ "eslintIgnore": [
114
+ "node_modules/",
115
+ "lib/"
116
+ ],
117
+ "prettier": {
118
+ "quoteProps": "consistent",
119
+ "singleQuote": true,
120
+ "tabWidth": 2,
121
+ "trailingComma": "es5",
122
+ "useTabs": false,
123
+ "semi": false
124
+ },
125
+ "react-native-builder-bob": {
126
+ "source": "src",
127
+ "output": "lib",
128
+ "exclude": "**/{__tests__,__fixtures__,__mocks__}/**,**/*.test.ts",
129
+ "targets": [
130
+ "commonjs",
131
+ "module",
132
+ [
133
+ "typescript",
134
+ {
135
+ "project": "tsconfig.build.json"
136
+ }
137
+ ]
138
+ ]
139
+ },
140
+ "cubismSdk": {
141
+ "latest": "5-r.5",
142
+ "versions": {
143
+ "5-r.5": {
144
+ "sha256": "7ff3a4bbc19c0a8728965aa522ab77eb11b252916453e68a8a78d3b71188bb12"
145
+ }
146
+ }
147
+ }
148
+ }
@@ -0,0 +1,14 @@
1
+ import { type ConfigPlugin } from 'expo/config-plugins';
2
+ export interface Live2dSkiaPluginProps {
3
+ /** Cubism SDK for Native folder (the one with `cubism-info.yml`), relative to the app's root. */
4
+ cubismSdkPath: string;
5
+ }
6
+ export declare function nativeProperties(projectRoot: string, platformProjectRoot: string, sdkPath: string): {
7
+ "live2dSkia.cubismSdkPath": string;
8
+ "live2dSkia.skiaPackage": string;
9
+ };
10
+ export declare const withLive2dSkia: ConfigPlugin<Live2dSkiaPluginProps | undefined>;
11
+ export declare function skiaPackageOf(projectRoot: string): string;
12
+ export declare function warnIfMissing(sdkDir: string): void;
13
+ declare const _default: ConfigPlugin<Live2dSkiaPluginProps | undefined>;
14
+ export default _default;
@@ -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
+ }