diff --git a/docs/docs/lib/media.md b/docs/docs/lib/media.md index 26b816b..da2a314 100644 --- a/docs/docs/lib/media.md +++ b/docs/docs/lib/media.md @@ -81,3 +81,322 @@ import { Character } from "../src/lib/sound/character" clipLabel="Voice" /> ``` + +### `` + +Controls animations such as lip-sync using a PSD file. +Within the `` component, dedicated components are used to control PSD options and render them onto a canvas. +You can create custom components, but you cannot use hooks internally. + +```tsx +import { BEZIER_SMOOTH } from "../src/lib/animation/functions" +import { seconds } from "../src/lib/frame" +import { PsdCharacter, MotionSequence, MotionWithVars, createSimpleLipSync } from "../src/lib/character/character-unit" + +const SimpleLipSync = createSimpleLipSync({ + kind: "bool", + options: { + Default: "表情/口/1", + Open: "表情/口/1", + Closed: "表情/口/5", + } +}) + + + + + { + await ctx.move(variables.t).to(1, seconds(1), BEZIER_SMOOTH) + }} + motion={(variables, frames) => { + const t = variables.time.get(frames[0]) + if (t > 0.5) { + return { + "表情/目/9": false, + "表情/目/17": true + } + } else { + return {} + } + }} + /> + + +``` + +The main components are as follows: + +#### `` + +Serializes child elements. +Internally, it uses ``, and each child is wrapped in a ``. + +```tsx +import { MotionSequence, Voice } from "../src/lib/character/character-unit" + + + + + +``` + + +#### `` + +Used directly under `MotionSequence` to run child elements in parallel. + +```tsx +import { MotionSequence, MotionClip, Voice } from "../src/lib/character/character-unit" + + + + + + + + +``` + + +#### `` + +Places an audio clip. +Internally, the audio is wrapped in a ``. + +```tsx +import { Voice } from "../src/lib/character/character-unit" + + +``` + + +#### `` + +Creates animations using variables. +First declare variables with `variables`, then define the animation with `animation`, and finally return PSD options with `motion`. + +The options must conform to the `data` argument of `renderPsd` from ag-psd-psdtool. + +Since hooks cannot be used under `PsdCharacter`, retrieve `Variable` values using `frames[0]` and the `get` method. + +`frames[0]` contains the frame number obtained from `useCurrentFrame`, but note that `MotionWithVars` itself is not wrapped in a ``. + +```tsx +import { BEZIER_SMOOTH } from "../src/lib/animation/functions" +import { seconds } from "../src/lib/frame" +import { MotionWithVars } from "../src/lib/character/character-unit" + + { + await ctx.move(variables.t).to(1, seconds(1), BEZIER_SMOOTH) + }} + motion={(variables, frames) => { + const t = variables.time.get(frames[0]) + if (t > 0.5) { + return { + "表情/目/9": false, + "表情/目/17": true + } + } else { + return {} + } + }} +/> +``` + + +#### `createSimpleLipSync` + +A function that returns a component for volume-based lip-sync compatible with PSD files. + +It takes a dictionary that maps mouth states in the PSD to layers/options and returns a component. + +Dictionary format: + +* When using psd-tool-kit: + + * Set `kind` to `enum` + * Specify the mouth layer in `Mouth` + * Specify the default option in `Default` + * Specify corresponding options in `Open` / `Closed` + +* When not using psd-tool-kit: + + * Set `kind` to `bool` + * Specify the default layer in `Default` + * Specify corresponding layers in `Open` / `Closed` + +```tsx +import { createSimpleLipSync } from "../src/lib/character/character-unit" + +const SimpleLipSync = createSimpleLipSync({ + kind: "bool", + options: { + Default: "表情/口/1", + Open: "表情/口/1", + Closed: "表情/口/5", + } +}) + + +``` + + +#### `createLipSync` + +A function that returns a component for vowel-based lip-sync compatible with PSD files. + +It takes a dictionary mapping mouth states to layers/options and returns a component. +The component receives timing data via `data` to control lip-sync. + +`data` is compatible with the output of rhubarb: +[https://github.com/DanielSWolf/rhubarb-lip-sync](https://github.com/DanielSWolf/rhubarb-lip-sync) + +Dictionary format is the same as above. + +```tsx +import { Voice, createLipSync } from "../src/lib/character/character-unit" + +const LipSync = createLipSync({ + kind: "enum", + options: { + Mouth: "表情/口", + Default: "1", + A: "1", + I: "2", + U: "3", + E: "4", + O: "5", + X: "6", + } +}) + +const lipsync = { + mouthCues: [ + { start: 0.00, end: 0.03, value: "A" }, + { start: 0.03, end: 0.09, value: "B" }, + { start: 0.09, end: 0.29, value: "C" } + ] +} + + + + + +``` + + +#### `createBlink` + +A function that returns a component for blinking compatible with PSD files. + +It takes a dictionary mapping eye states to layers/options and returns a component. +The component receives timing data via `data` to control blinking. + +Mapping for `data.value`: + +| value | option | +| ----- | ------------ | +| "A" | "Open" | +| "B" | "HalfOpen" | +| "C" | "HalfClosed" | +| "D" | "Closed" | + +```tsx +import { Voice, createBlink, generateBlinkData } from "../src/lib/character/character-unit" + +const Blink = createBlink({ + kind: "enum", + options: { + Mouth: "表情/目", + Default: "1", + Open: "1", + HalfOpen: "2", + HalfClosed: "3", + Closed: "4", + } +}) + +// const blink = generateBlinkData(0, 10) +const blink = { + blinkCues: [ + { start: 0.00, end: 0.01, value: "A" }, + { start: 0.01, end: 0.02, value: "B" }, + { start: 0.02, end: 0.03, value: "C" }, + { start: 0.03, end: 0.04, value: "D" }, + { start: 0.04, end: 0.05, value: "C" }, + { start: 0.05, end: 0.06, value: "B" }, + { start: 0.06, end: 0.07, value: "A" } + ] +} + + + + + +``` + + +### `` + +Creates a dialogue-style scenario using PSD-based character images. + +Main child components: + + +#### `` + +Declares characters to be used, with `` as children. + + +#### `` + +Declares a character inside ``. +Can accept the same child components as ``, which will be used as behavior when the character is not speaking. + + +#### `` + +Defines a conversation by arranging `` components. + + +#### `` + +Defines a unit of dialogue inside ``. +Use `` to declare the speaker. +Other React components can also be placed. + + +#### `` + +Declares the behavior of the speaking character inside ``. +Accepts the same child components as ``. +Specify the `name` declared in `` to display the corresponding PSD. + +```tsx +import { DialogueScenario, DeclareCharacters, DeclareCharacter, Scenario, Chapter, Speaker } from "../src/lib/character/character-manager" +import { Voice } from "../src/lib/character/character-unit" + + + + + + + + + + + + + + + + + + + +``` + diff --git a/docs/i18n/ja/docusaurus-plugin-content-docs/current/lib/media.md b/docs/i18n/ja/docusaurus-plugin-content-docs/current/lib/media.md index effed69..e400bd1 100644 --- a/docs/i18n/ja/docusaurus-plugin-content-docs/current/lib/media.md +++ b/docs/i18n/ja/docusaurus-plugin-content-docs/current/lib/media.md @@ -79,3 +79,346 @@ import { Character } from "../src/lib/sound/character" clipLabel="Voice" /> ``` + +### `` + +PSD形式の立ち絵を利用した口パクなどのアニメーションを宣言します。 +``コンポーネント内で、専用のコンポーネントを利用してPSDのオプションを制御し、canvasへ描画します。 +コンポーネントを作成することもできますが、内部でフックを使うことは出来ません。 + +```tsx +import { BEZIER_SMOOTH } from "../src/lib/animation/functions" +import { seconds } from "../src/lib/frame" +import { PsdCharacter, MotionSequence, MotionWithVars, createSimpleLipSync } from "../src/lib/character/character-unit" + + +const SimpleLipSync = createSimpleLipSync({ + kind: "bool", + options: { + Default: "表情/口/1", + Open: "表情/口/1", + Closed: "表情/口/5", + } +}) + + + + + { + await ctx.move(variables.t).to(1, seconds(1), BEZIER_SMOOTH) + + }} + motion={(variables, frames) => { + const t = variables.time.get(frames[0]) + if (t > 0.5) { + return { + "表情/目/9": false, + "表情/目/17": true + } + } else { + return {} + } + }} + /> + + + +``` + +主なコンポーネントは次の通りです。 + +#### `` + +子要素を直列化します。 +内部的には``を利用しており、子要素は``で囲われます。 + +```tsx +import { MotionSequence, Voice } from "../src/lib/character/character-unit" + + + + + +``` + +#### `` + +MotionSequence直下で使用して、子要素を並列化します。 + +```tsx +import { MotionSequence, MotionClip, Voice } from "../src/lib/character/character-unit" + + + + + + + + +``` + +#### `` + +音声を配置します。 +内部的には音声はClipで囲われます。 + +```tsx +import { Voice } from "../src/lib/character/character-unit" + + +``` + +#### `` + +変数を使用したアニメーションを作成します。 +`variables`で変数を宣言し、次に`animation`でアニメーションを宣言し、最後に`motion`でPSDのオプションを返します。 +オプションはag-psd-psdtoolの`renderPsd`の引数`data`に準拠します。 + +`PsdCharacter`以下ではフックが使えないため、`Variable`の値は`frames[0]`を利用して、`get`メソッドから得てください。 + +`frames[0]`には`useCurrentFrame`で得られるフレーム数が入っていますが、`MotionWithVars`自体は``で囲われないことに注意してください。 + +```tsx +import { BEZIER_SMOOTH } from "../src/lib/animation/functions" +import { seconds } from "../src/lib/frame" +import { MotionWithVars } from "../src/lib/character/character-unit" + + { + await ctx.move(variables.t).to(1, seconds(1), BEZIER_SMOOTH) + + }} + motion={(variables, frames) => { + const t = variables.time.get(frames[0]) + if (t > 0.5) { + return { + "表情/目/9": false, + "表情/目/17": true + } + } else { + return {} + } + }} +/> +``` + +#### `createSimpleLipSync` + +PSDファイルに対応した音量ベースの口パクを行うコンポーネントを返す関数です。 +PSDの口の状態をレイヤー、オプションに対応させる辞書を受け取り、コンポーネントを返します。 +辞書は次のように指定します。 + +- psd-tool-kitに対応している場合 + + `kind`には`enum`を指定します。 + + `Mouth`にはPSDの口のレイヤーを指定します。 + + `Default`にはPSDファイルがデフォルトで表示する口のオプションを指定します。 + + `Open` / `Closed`にはそれぞれ対応するオプションを指定します。 + +- psd-tool-kitに対応していない場合 + + `kind`には`bool`を指定します。 + + `Default`にはPSDファイルがデフォルトで表示する口のレイヤーを指定します。 + + `Open` / `Closed`にはそれぞれ対応するレイヤーを指定します。 + +```tsx +import { createSimpleLipSync } from "../src/lib/character/character-unit" + +const SimpleLipSync = createSimpleLipSync({ + kind: "bool", + options: { + Default: "表情/口/1", + Open: "表情/口/1", + Closed: "表情/口/5", + } +}) + + +``` + +#### `createLipSync` + +PSDファイルに対応した母音ベースの口パクを行うコンポーネントを返す関数です。 + +PSDの口の状態をレイヤー、オプションに対応させる辞書を受け取り、コンポーネントを返します。 +コンポーネントは`data`としてタイミング情報を受け取り、口パクを制御します。 +`data`はrhubarb( https://github.com/DanielSWolf/rhubarb-lip-sync )の出力に対応します。 +辞書は次のように指定します。 + +- psd-tool-kitに対応している場合 + + `kind`には`enum`を指定します。 + + `Mouth`にはPSDの口のレイヤーを指定します。 + + `Default`にはPSDファイルがデフォルトで表示する口のオプションを指定します。 + + `Open` / `Closed`にはそれぞれ対応するオプションを指定します。 + +- psd-tool-kitに対応していない場合 + + `kind`には`bool`を指定します。 + + `Default`にはPSDファイルがデフォルトで表示する口のレイヤーを指定します。 + + `Open` / `Closed`にはそれぞれ対応するレイヤーを指定します。 + + +```tsx +import { createLipSync } from "../src/lib/character/character-unit" + +const LipSync = createLipSync({ + kind: "enum", + options: { + Mouth: "表情/口", + Default: "1", + A: "1", + I: "2", + U: "3", + E: "4", + O: "5", + X: "6", + } +}) + +const lipsync = { + mouthCues: [ + { start: 0.00, end: 0.03, value: "A" }, + { start: 0.03, end: 0.09, value: "B" }, + { start: 0.09, end: 0.29, value: "C" } +} + + + + + +``` + +#### `createBlink` + +PSDファイルに対応した目パチを行うコンポーネントを返す関数です。 + +PSDの目の状態をレイヤー、オプションに対応させる辞書を受け取り、コンポーネントを返します。 +コンポーネントは`data`としてタイミング情報を受け取り、目パチを制御します。 +辞書は次のように指定します。 + +- psd-tool-kitに対応している場合 + + `kind`には`enum`を指定します。 + + `Mouth`にはPSDの口のレイヤーを指定します。 + + `Default`にはPSDファイルがデフォルトで表示する口のオプションを指定します。 + + `Open` / `Closed`にはそれぞれ対応するオプションを指定します。 + +- psd-tool-kitに対応していない場合 + + `kind`には`bool`を指定します。 + + `Default`にはPSDファイルがデフォルトで表示する口のレイヤーを指定します。 + + `Open` / `Closed`にはそれぞれ対応するレイヤーを指定します。 + +`data`のvalueは次のように対応します。 +| value | option | +| ---- | ---- | +| "A" | "Open" | +| "B" | "HalfOpen" | +| "C" | "HalfClosed" | +| "D" | "Closed" | + + + +```tsx +import { createBlink, generateBlinkData } from "../src/lib/character/character-unit" + +const Blink = createBlink({ + kind: "enum", + options: { + Mouth: "表情/目", + Default: "1", + Open: "1", + HalfOpen: "2", + HalfClosed: "3", + Closed: "4", + } +}) + +// const blink = generateBlinkData(0, 10) +const blink = { + blinkCues: [ + { start: 0.00, end: 0.01, value: "A" }, + { start: 0.01, end: 0.02, value: "B" }, + { start: 0.02, end: 0.03, value: "C" }, + { start: 0.03, end: 0.04, value: "D" }, + { start: 0.04, end: 0.05, value: "C" }, + { start: 0.05, end: 0.06, value: "B" }, + { start: 0.06, end: 0.07, value: "A" } +} + + + + + +``` + +### `` + +会話形式のシナリオにおいてPSD形式の立ち絵を利用して口パクなどのアニメーションを制御します。 + +子要素として使用できる主なコンポーネントは次の通りです。 + +#### `` +``を子要素にとり、利用するキャラクターを宣言します。 + +#### `` +``内で使用して利用するキャラクターを宣言します。 +子要素として``の子要素と同様のコンポーネントを取ることができ、非話者時の動作として割り当てられます。 + +#### `` +``を並べて会話を宣言します。 + +#### `` +``内で使用してキャラクターの話す単位を宣言します。 +``を使用して話者を宣言します。 +その他Reactコンポーネントを配置することもできます。 + +#### `` +``内で使用して、話者の動作を宣言します。 +``の子要素と同様のコンポーネントを取ります。 +``で宣言した`name`を指定して、対応するPSDを表示します。 + + +```tsx +import { DialogueScenario, DeclareCharacters, DeclareCharacter, Scenario, Chapter, Speaker } from "../src/lib/character/character-manager" +import { Voice } from "../src/lib/character/character-unit" + + + + + + + + + + + + + + + + + + + +``` + diff --git a/package-lock.json b/package-lock.json index 7f258af..da0a034 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "@ffmpeg-installer/ffmpeg": "^1.1.0", "@ffprobe-installer/ffprobe": "^2.1.2", "@types/opentype.js": "^1.3.4", + "ag-psd": "^30.1.0", + "ag-psd-psdtool": "^1.1.10", "mathjax-full": "^3.2.1", "opentype.js": "^1.3.4", "prismjs": "^1.30.0", @@ -1410,308 +1412,325 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "openharmony" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -2135,13 +2154,12 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, - "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -2278,6 +2296,46 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/ag-psd": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/ag-psd/-/ag-psd-30.1.0.tgz", + "integrity": "sha512-1ce6o84aC+oVyl83A35HHUniGjwA3piHmGem3J2odBOFRq5p7i4htaco94vePLfOcingZu4fsyospJLwPagkhg==", + "dependencies": { + "base64-js": "1.5.1", + "pako": "2.1.0" + } + }, + "node_modules/ag-psd-psdtool": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/ag-psd-psdtool/-/ag-psd-psdtool-1.1.10.tgz", + "integrity": "sha512-UgyFPtgToJImsCHl9szHMxqYR0VfPZm3b7TIrbrIk7GrNLaAARqjQvTptGVRMsm1WgTDUTJZwd3kOk83wEWnfg==", + "hasInstallScript": true, + "dependencies": { + "ag-psd": "^30.1.0", + "ajv": "^8.18.0", + "es-toolkit": "^1.45.1" + } + }, + "node_modules/ag-psd-psdtool/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ag-psd-psdtool/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -2288,11 +2346,10 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, - "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2477,6 +2534,25 @@ "bare-path": "^3.0.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/baseline-browser-mapping": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.2.tgz", @@ -2488,10 +2564,9 @@ } }, "node_modules/basic-ftp": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", - "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", - "license": "MIT", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", + "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", "engines": { "node": ">=10.0.0" } @@ -3147,6 +3222,11 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==" + }, "node_modules/es6-error": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", @@ -3477,7 +3557,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-fifo": { @@ -3500,6 +3579,21 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -4364,11 +4458,10 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -4587,6 +4680,11 @@ "node": ">= 14" } }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4897,6 +4995,14 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-alpn": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", @@ -4946,11 +5052,10 @@ } }, "node_modules/rollup": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, - "license": "MIT", "dependencies": { "@types/estree": "1.0.8" }, @@ -4962,28 +5067,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, diff --git a/package.json b/package.json index 0d33137..f740a47 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "@ffmpeg-installer/ffmpeg": "^1.1.0", "@ffprobe-installer/ffprobe": "^2.1.2", "@types/opentype.js": "^1.3.4", + "ag-psd": "^30.1.0", + "ag-psd-psdtool": "^1.1.10", "mathjax-full": "^3.2.1", "opentype.js": "^1.3.4", "prismjs": "^1.30.0", diff --git a/src/lib/animation.ts b/src/lib/animation.ts index bab9946..3b0aa9c 100644 --- a/src/lib/animation.ts +++ b/src/lib/animation.ts @@ -82,7 +82,7 @@ type MoveController = { to: (value: T, durationFrames: number, easing?: Easing) => AnimationHandle } -type AnimationContext = { +export type AnimationContext = { sleep: (frames: number) => AnimationHandle waitUntil: (frame: number) => AnimationHandle waitUntilClip: (label: string) => AnimationHandle diff --git a/src/lib/character/character-manager/ast.ts b/src/lib/character/character-manager/ast.ts new file mode 100644 index 0000000..f8c2f12 --- /dev/null +++ b/src/lib/character/character-manager/ast.ts @@ -0,0 +1,56 @@ +import type { ReactNode } from "react" + +export const CharacterManagerElement = { + CharacterManager: "CharacterManager", + DeclareCharacters: "DeclareCharacters", + Scenario: "Scenario", + DeclareCharacter: "DeclareCharacter", + Chapter: "Chapter", + Speaker: "Speaker", +} as const + +export type ChapterChild = + | { kind: "speaker", node: SpeakerNode } + | { kind: "other", node: ReactNode } + + +/* ========================= + Nodes +========================= */ + +export interface CharacterManagerNode { + type: typeof CharacterManagerElement.CharacterManager + characters: DeclareCharactersNode + scenario: ScenarioNode +} + +export interface DeclareCharactersNode { + type: typeof CharacterManagerElement.DeclareCharacters + children: DeclareCharacterNode[] +} + +export interface ScenarioNode { + type: typeof CharacterManagerElement.Scenario + children: ChapterNode[] +} + +export interface DeclareCharacterNode { + type: typeof CharacterManagerElement.DeclareCharacter + idleClassName?: string + speakingClassName?: string + name: string + psd: string + children: ReactNode +} + +export interface ChapterNode { + type: typeof CharacterManagerElement.Chapter + children: ChapterChild[] +} + +export interface SpeakerNode { + type: typeof CharacterManagerElement.Speaker + className?: string + name: string + children: ReactNode +} diff --git a/src/lib/character/character-manager/character-manager-component.ts b/src/lib/character/character-manager/character-manager-component.ts new file mode 100644 index 0000000..b0c0736 --- /dev/null +++ b/src/lib/character/character-manager/character-manager-component.ts @@ -0,0 +1,129 @@ +import type { ReactElement } from "react" +import { defineDSL } from "../utils/defineDSL" +import { CharacterManagerElement } from "./ast" +import type { OneOrMany } from "../utils/util-types" + +// ================================ +// Utility Types +// ================================ + +/** + * Allow a single ReactElement or an array of them. + * ReactElementを単体または配列で受け取れるようにするユーティリティ型 + */ +type ChildrenOf = OneOrMany> + + +// ================================ +// DSL Definitions (Scenario Builder) +// ================================ + +/** + * Declare all characters used in the scenario. + * This acts as a registry for characters before they are used. + * + * シナリオ内で使用するキャラクターをまとめて宣言するコンテナ。 + * 後続のChapterやSpeakerから参照される前提のレジストリとして機能する。 + * + * @example + * + * + * + */ +export const DeclareCharacters = defineDSL<{ + children: ChildrenOf +}>(CharacterManagerElement.DeclareCharacters) + + +/** + * Root container of the scenario. + * Accepts multiple Chapter elements. + * + * シナリオ全体を構成するルートコンテナ。 + * 子要素として複数のChapterを持つ。 + * + */ +export const Scenario = defineDSL<{ + children?: ChildrenOf +}>(CharacterManagerElement.Scenario) + + +/** + * Declare a character and its base (idle) state. + * This definition is later referenced by . + * + * キャラクターとそのデフォルト状態(非話者状態)を定義する。 + * この定義は後からSpeakerで参照される。 + * + * @param idleClassName CSS class applied when the character is idle + * @param speakingClassName CSS class applied when the character is speaking + * @param name Unique identifier used inside the scenario + * @param psd PSD resource path + * @param children Defines the idle visual state (same as PsdCharacter children) + * + * @param idleClassName 非話者時のclassName + * @param speakingClassName 話者時のclassName + * @param name シナリオ内で使用する一意な名前 + * @param psd 使用するPSDリソース + * @param children 非話者時の見た目(PsdCharacterと同様) + * + * @example + * ```tsx + * + * {return {}}} /> + * + * ``` + */ +export const DeclareCharacter = defineDSL<{ + idleClassName?: string + speakingClassName?: string + name: string + psd: string + children?: React.ReactNode +}>(CharacterManagerElement.DeclareCharacter) + + +/** + * Defines a scene (or segment) where characters can speak. + * Inside this block, speakers are explicitly assigned. + * + * キャラクターが発話する単位(シーン・チャプター)を定義する。 + * この中でSpeakerとして指定されたキャラが発話状態になる。 + * + * @behavior + * - Characters assigned as Speaker → speaking state + * - Others → remain in idle state + * + * 挙動: + * - Speakerに指定されたキャラ → 話者状態 + * - それ以外 → 非話者状態のまま + */ +export const Chapter = defineDSL<{ + children: React.ReactNode +}>(CharacterManagerElement.Chapter) + + +/** + * Assign a character as the active speaker within a Chapter. + * Overrides the default idle state. + * + * Chapter内でキャラクターを話者として登録する。 + * デフォルトの非話者状態を上書きする。 + * + * @param className CSS class applied to canvas + * @param name Must match a declared character name + * @param children Defines speaking state (same as PsdCharacter children) + * + * @param className canvasに適用されるclassName + * @param name DeclareCharacterで定義したキャラ名と一致する必要がある + * @param children 発話時の状態(PsdCharacterと同様) + * + * @important + * The name must match a declared character. + * nameは必ずDeclareCharacterで定義したものと一致させる必要がある + */ +export const Speaker = defineDSL<{ + className?: string + name: string + children: React.ReactNode +}>(CharacterManagerElement.Speaker) diff --git a/src/lib/character/character-manager/character-manager.tsx b/src/lib/character/character-manager/character-manager.tsx new file mode 100644 index 0000000..13e0dbd --- /dev/null +++ b/src/lib/character/character-manager/character-manager.tsx @@ -0,0 +1,259 @@ +import type { ReactElement, ReactNode } from "react" +import { parseCharacterManager } from "./parser" +import { PsdCharacter } from "../character-unit" +import { DeclareCharacters, Scenario } from "./character-manager-component" +import { Clip, ClipSequence } from "../../clip" +import type { OneOrMany } from "../utils/util-types" + +// ================================ +// Types +// ================================ + +/** + * Determines where implicit (non-speaking) characters are placed. + * + * 非話者キャラクターをどのレイヤー順で配置するかを指定する + * - "front": 前面に配置 + * - "back": 背面に配置 + */ +export type ImplicitCharacterPlacement = "front" | "back" + +type DialogueScenarioProps = { + implicitPlacement?: ImplicitCharacterPlacement + + /** + * Accepts DSL components: + * - DeclareCharacters + * - Scenario + * + * DSLコンポーネントを受け取る: + * - DeclareCharacters(キャラ定義) + * - Scenario(シナリオ本体) + */ + children: OneOrMany | ReactElement> +} + + +// ================================ +// Main Component +// ================================ + +/** + * Builds a dialogue-style scenario from declared characters and chapters. + * Renders each scene with speaking and non-speaking characters automatically arranged. + * + * キャラクター定義とチャプター構成から、会話形式のシナリオを生成する。 + * 各シーンごとに、話者・非話者のキャラクターを自動で配置して描画する。 + * + * @param implicitPlacement Controls layering of non-speaking characters + * @param children DSL components describing characters and scenario + * + * @param implicitPlacement 非話者キャラクターの前後配置 + * @param children シナリオDSL + * + * @example + * ```tsx + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * ``` + */ +export const DialogueScenario = ({ + implicitPlacement = "back", + children +}: DialogueScenarioProps) => { + + // ========================= + // 1. Parse DSL → AST + // ========================= + const ast = parseCharacterManager(children) + + // ========================= + // 2. Build character registry + // ========================= + /** + * Map + * + * キャラクター名をキーにした辞書を構築 + * - psd: 使用PSD + * - speakingClassName: 話者時スタイル + * - idleState: 非話者時の描画要素 + */ + const characters = new Map( + ast.characters.children.map(character => { + return [ + character.name, + { + psd: character.psd, + speakingClassName: character.speakingClassName, + + // Pre-built idle state (used when not speaking) + // 非話者状態の描画をあらかじめ構築しておく + idleState: ( + + {character.children} + + ) + } + ] + }) + ) + + // ========================= + // 3. Build scenario per chapter + // ========================= + const scenario = ast.scenario.children.map(chapter => { + + // ------------------------- + // Extract explicit speakers + // ------------------------- + /** + * Characters explicitly marked as speakers in this chapter + * + * このチャプター内でSpeakerとして明示指定されたキャラクター + */ + const explicitSpeakers = chapter.children + .filter(child => child.kind == "speaker") + .map(s => s.node.name) + + // ------------------------- + // Determine implicit characters + // ------------------------- + /** + * Characters NOT speaking in this chapter + * + * このチャプターで話していないキャラクター + */ + const implicitCharacters = Array.from(characters.entries()) + .filter(([key, _]) => !explicitSpeakers.includes(key)) + + // ------------------------- + // Build explicit speaker nodes + // ------------------------- + const explicits = chapter.children.map(elm => { + + if (elm.kind == "speaker") { + + // Resolve default speaking class + // デフォルトの話者classNameを付与 + let defaultClass = "" + if (characters.get(elm.node.name)?.speakingClassName) { + defaultClass = " " + characters.get(elm.node.name)!.speakingClassName + } + + // Merge user-defined + default class + // ユーザー指定とデフォルトを結合 + const className = + (elm.node.className ? elm.node.className : "") + defaultClass + + return ( + + {elm.node.children} + + ) + } else { + // Non-speaker elements are passed through as-is + // speaker以外の要素はそのまま返す + return elm.node + } + }) + + // ------------------------- + // Build implicit (idle) nodes + // ------------------------- + const implicits = implicitCharacters.map( + ([_, character]) => character.idleState + ) + + // ------------------------- + // Merge explicit & implicit + // ------------------------- + /** + * Merge based on placement rule + * + * implicitPlacementに応じて前後関係を決定 + */ + const merged = mergeImplicitCharacters( + implicitPlacement, + explicits, + implicits + ) + + // Wrap each chapter as a Clip + // 各チャプターをClipとしてラップ + return {merged} + }) + + // ========================= + // 4. Return sequence + // ========================= + /** + * Final output is a sequence of clips + * + * 最終的にClipの連続として出力 + */ + return ( + + {scenario} + + ) +} + + +// ================================ +// Helpers +// ================================ + +/** + * Merge explicit (speaking) and implicit (idle) characters + * based on placement rule. + * + * 話者と非話者の描画順を制御する + * + * @param implicitPlacement front or back + * @param explicits speaking elements + * @param implicits idle elements + */ +const mergeImplicitCharacters = ( + implicitPlacement: ImplicitCharacterPlacement, + explicits: ReactNode[], + implicits: ReactNode[] +) => { + switch (implicitPlacement) { + case "front": + // Place implicit characters in front + // 非話者を前面に配置 + return [...explicits, ...implicits] + + case "back": + // Place implicit characters behind + // 非話者を背面に配置 + return [...implicits, ...explicits] + + default: + throw `unknown merge option: {implicitPlacement}` + } +} diff --git a/src/lib/character/character-manager/index.ts b/src/lib/character/character-manager/index.ts new file mode 100644 index 0000000..64a0533 --- /dev/null +++ b/src/lib/character/character-manager/index.ts @@ -0,0 +1,2 @@ +export * from "./character-manager" +export * from "./character-manager-component" diff --git a/src/lib/character/character-manager/parser.tsx b/src/lib/character/character-manager/parser.tsx new file mode 100644 index 0000000..34ea205 --- /dev/null +++ b/src/lib/character/character-manager/parser.tsx @@ -0,0 +1,152 @@ +import React, { isValidElement, type ReactElement, type ReactNode } from "react" +import type { CharacterManagerNode, DeclareCharactersNode, ScenarioNode, DeclareCharacterNode, ChapterNode, SpeakerNode, ChapterChild } from "./ast" +import { CharacterManagerElement as ManagerElm} from "./ast" +import { Motion } from "../character-unit" + + +type AnyElement = ReactElement + + +export const parseCharacterManager = ( + children: ReactNode, +): CharacterManagerNode => { + const childrenArray = React.Children.toArray(children) + if (childrenArray.length != 2) { + throw "CharacterManager need DeclareCharacters and Scenario." + } + + if (!isValidElement(childrenArray[0]) || !isValidElement(childrenArray[1])) { + throw new Error(`Invalid Element in ${ManagerElm.CharacterManager}`) + } + + const characters = parseDeclareCharacters(childrenArray[0]) + const scenario = parseScenario(childrenArray[1]) + + return { + type: ManagerElm.CharacterManager, + characters: characters, + scenario: scenario, + } +} + +const parseDeclareCharacters = ( + self: AnyElement +): DeclareCharactersNode => { + const { children } = self.props + const body = parseDeclareCharactersChildren(children) + return { + type: ManagerElm.DeclareCharacters, + children: body, + } +} + +const parseDeclareCharactersChildren = ( + children: ReactNode +): DeclareCharacterNode[] => { + return React.Children.map(children, (child) => { + if (!isValidElement(child)) return + + const type = getDslType(child) + if (type == ManagerElm.DeclareCharacter) { + return parseDeclareCharacter(child) + } else { + throw `Invalid DSL type in ${ManagerElm.DeclareCharacters}: ${type}` + } + }) ?? [] + +} + +const parseScenario = ( + self: AnyElement +): ScenarioNode => { + const { children } = self.props + const body = parseScenarioChildren(children) + return { + type: ManagerElm.Scenario, + children: body, + } +} + +const parseScenarioChildren = ( + children: ReactNode +): ChapterNode[] => { + return React.Children.map(children, (child) => { + if (!isValidElement(child)) return + + const type = getDslType(child) + if (type == ManagerElm.Chapter) { + return parseChapter(child) + } else { + throw `Invalid DSL type in ${ManagerElm.DeclareCharacters}: ${type}` + } + }) ?? [] + +} + +const parseDeclareCharacter = ( + self: AnyElement +): DeclareCharacterNode => { + const { name, psd, idleClassName, speakingClassName, children } = self.props + let comp_child = {return {}}} /> + if (children) { + comp_child = children + } + return { + type: ManagerElm.DeclareCharacter, + name, + psd, + idleClassName, + speakingClassName, + children: comp_child, + } +} + +const parseChapter = ( + self: AnyElement +): ChapterNode => { + const { children } = self.props + const body = parseChapterChildren(children) + return { + type: ManagerElm.Chapter, + children: body, + } +} + +const parseChapterChildren = ( + children: ReactNode +): ChapterChild[] => { + return React.Children.map(children, child => { + if (!isValidElement(child)) return + + const type = getDslType(child) + if (type == ManagerElm.Speaker) { + return { kind: "speaker", node: parseSpeaker(child) } + } else { + return { kind: "other", node: child } + } + }) ?? [] + +} + +const parseSpeaker = ( + self: AnyElement +): SpeakerNode => { + const { className, name, children } = self.props + return { + type: ManagerElm.Speaker, + className, + name, + children, + } +} + + +const getDslType = (el: AnyElement): string | undefined => { + const type = el.type as any + + if (type?.__dslType) { + return type.__dslType + } + + return undefined +} diff --git a/src/lib/character/character-unit/ast.ts b/src/lib/character/character-unit/ast.ts new file mode 100644 index 0000000..9478d28 --- /dev/null +++ b/src/lib/character/character-unit/ast.ts @@ -0,0 +1,91 @@ +import type { AnimationContext, Variable, VariableType } from "../../animation" +import type { AudioSegment } from "../../audio-plan" +import type { WaveformData } from "../../audio-waveform" +import type { Trim } from "../../trim" + +export const PsdCharacterElement = { + Character: "Character", + MotionSequence: "MotionSequence", + DeclareVariable: "DeclareVariable", + MotionClip: "MotionClip", + DeclareAnimation: "DeclareAnimation", + Voice: "Voice", + Motion: "Motion", +} as const + + +export type CharacterChild = + | MotionSequenceNode + | DeclareVariableNode + | VoiceNode + | MotionNode + +export type MotionSequenceChild = + | MotionClipNode + | DeclareVariableNode + | VoiceNode + | MotionNode + +export type DeclareVariableChild = + | DeclareVariableNode + | DeclareAnimationNode + +export type MotionClipChild = + | MotionSequenceNode + | DeclareVariableNode + | VoiceNode + | MotionNode + +export type DeclareAnimationChild = + | MotionSequenceNode + | DeclareVariableNode + | VoiceNode + | MotionNode + + + + +export interface CharacterNode { + type: typeof PsdCharacterElement.Character + children: CharacterChild[] +} + +export interface MotionSequenceNode { + type: typeof PsdCharacterElement.MotionSequence + children: MotionSequenceChild[] +} + +export interface DeclareVariableNode { + type: typeof PsdCharacterElement.DeclareVariable + variableName: string + initValue: VariableType + children: DeclareVariableChild +} + +export interface MotionClipNode { + type: typeof PsdCharacterElement.MotionClip + children: MotionClipChild[] +} + +export interface DeclareAnimationNode { + type: typeof PsdCharacterElement.DeclareAnimation + animation: (ctx: AnimationContext, variable: Record>) => Promise + children: DeclareAnimationChild[] +} + +export interface VoiceNode { + type: typeof PsdCharacterElement.Voice + voice: string + voiceMotion?: (segment: AudioSegment, waveform: WaveformData | null, variables: Record>, frames: number[]) => Record + trim?: Trim + fadeInFrames?: number + fadeOutFrames?: number + volume: undefined | number | ((variables: Record>, frames: number[]) => number) + showWaveform?: boolean +} + +export interface MotionNode { + type: typeof PsdCharacterElement.Motion + motion: (variables: Record>, frames: number[]) => Record +} + diff --git a/src/lib/character/character-unit/index.ts b/src/lib/character/character-unit/index.ts new file mode 100644 index 0000000..568f16d --- /dev/null +++ b/src/lib/character/character-unit/index.ts @@ -0,0 +1,3 @@ +export * from "./psd-character" +export * from "./psd-character-component" +export * from "./util-motions" diff --git a/src/lib/character/character-unit/parser.tsx b/src/lib/character/character-unit/parser.tsx new file mode 100644 index 0000000..f4efb12 --- /dev/null +++ b/src/lib/character/character-unit/parser.tsx @@ -0,0 +1,295 @@ +import React, { isValidElement, type ReactElement, type ReactNode } from "react" +import type { MotionClipChild, MotionClipNode, CharacterChild, CharacterNode, DeclareAnimationChild, DeclareAnimationNode, DeclareVariableChild, DeclareVariableNode, MotionNode, MotionSequenceChild, MotionSequenceNode, VoiceNode } from "./ast" +import { PsdCharacterElement as PsdElm } from "./ast" + +type AnyElement = ReactElement + + +export const parsePsdCharacter = ( + children: ReactNode, +): CharacterNode => { + const body = parsePsdCharacterChildren(children) + return { + type: PsdElm.Character, + children: body, + } +} + +const parsePsdCharacterChildren = ( + children: ReactNode, +): CharacterChild[] => { + const result: CharacterChild[] = [] + + React.Children.forEach(children, (child) => { + if (!isValidElement(child)) return + + const type = getDslType(child) + + switch (type) { + case PsdElm.MotionSequence: + result.push(parseMotionSequence(child)) + break + + case PsdElm.DeclareVariable: + result.push(parseDeclareVariable(child)) + break + + case PsdElm.Voice: + result.push(parseVoice(child)) + break + case PsdElm.Motion: + result.push(parseMotion(child)) + break + case "function": + const expanded = child.type(child.props) + const expandedAst = parsePsdCharacterChildren(expanded) + result.push(...expandedAst) + break + + default: + throw new Error(`Invalid DSL type in root: ${type}`) + } + }) + + return result +} + +const parseMotionSequence = ( + self: AnyElement, +): MotionSequenceNode => { + const { children } = self.props + const body = parseMotionSequenceChildren(children) + return { + type: PsdElm.MotionSequence, + children: body, + } +} + +const parseMotionSequenceChildren = ( + children: ReactNode, +): MotionSequenceChild[] => { + const result: MotionSequenceChild[] = [] + + React.Children.forEach(children, (child) => { + if (!isValidElement(child)) return + + const type = getDslType(child) + + switch (type) { + case PsdElm.MotionClip: + result.push(parseMotionClip(child)) + break + + case PsdElm.DeclareVariable: + result.push(parseDeclareVariable(child)) + break + + case PsdElm.Voice: + result.push(parseVoice(child)) + break + case PsdElm.Motion: + result.push(parseMotion(child)) + break + case "function": + const expanded = child.type(child.props) + const expandedAst = parseMotionSequenceChildren(expanded) + result.push(...expandedAst) + break + + default: + throw new Error(`Invalid DSL type in ${PsdElm.MotionSequence}: ${type}`) + } + }) + + return result +} + +const parseDeclareVariable = ( + self: AnyElement, +): DeclareVariableNode => { + const { variableName, initValue, children } = self.props + const body = parseDeclareVariableChild(children) + + return { + type: PsdElm.DeclareVariable, + variableName, + initValue, + children: body, + } +} + + +const parseDeclareVariableChild = ( + children: ReactNode, +): DeclareVariableChild => { + + const single = React.Children.toArray(children) + if (single.length == 1) { + const child = single[0] + + if (!isValidElement(child)) { + throw new Error(`Invalid Element in ${PsdElm.DeclareVariable}`) + } + + const type = getDslType(child) + + + switch (type) { + case PsdElm.DeclareVariable: + return parseDeclareVariable(child) + + case PsdElm.DeclareAnimation: + return parseDeclareAnimation(child) + case "function": + const expanded = child.type(child.props) + const expandedAst = parseDeclareVariable(expanded) + return expandedAst + + default: + throw new Error(`Invalid DSL type in ${PsdElm.DeclareVariable}: ${type}`) + } + } else { + throw new Error(`${PsdElm.DeclareVariable} take just one element`) + } +} + +const parseMotionClip = ( + self: AnyElement, +): MotionClipNode => { + const { children } = self.props + const body = parseMotionClipChildren(children) + return { + type: PsdElm.MotionClip, + children: body, + } +} + +const parseMotionClipChildren = ( + children: ReactNode, +): MotionClipChild[] => { + const result: MotionClipChild[] = [] + + React.Children.forEach(children, (child) => { + if (!isValidElement(child)) return + + const type = getDslType(child) + + switch (type) { + case PsdElm.MotionSequence: + result.push(parseMotionSequence(child)) + break + + case PsdElm.DeclareVariable: + result.push(parseDeclareVariable(child)) + break + + case PsdElm.Voice: + result.push(parseVoice(child)) + break + case PsdElm.Motion: + result.push(parseMotion(child)) + break + case "function": + const expanded = child.type(child.props) + const expandedAst = parseMotionClipChildren(expanded) + result.push(...expandedAst) + break + + default: + throw new Error(`Invalid DSL type in ${PsdElm.MotionClip}: ${type}`) + } + }) + + return result +} + +const parseDeclareAnimation = ( + self: AnyElement, +): DeclareAnimationNode => { + const { animation, children } = self.props + const body = parseDeclareAnimationChildren(children) + return { + type: PsdElm.DeclareAnimation, + animation: animation, + children: body, + } +} + +const parseDeclareAnimationChildren = ( + children: ReactNode, +): DeclareAnimationChild[] => { + const result: DeclareAnimationChild[] = [] + + React.Children.forEach(children, (child) => { + if (!isValidElement(child)) return + + const type = getDslType(child) + + switch (type) { + case PsdElm.MotionSequence: + result.push(parseMotionSequence(child)) + break + + case PsdElm.DeclareVariable: + result.push(parseDeclareVariable(child)) + break + + case PsdElm.Voice: + result.push(parseVoice(child)) + break + case PsdElm.Motion: + result.push(parseMotion(child)) + break + case "function": + const expanded = child.type(child.props) + const expandedAst = parseDeclareAnimationChildren(expanded) + result.push(...expandedAst) + break + + default: + throw new Error(`Invalid DSL type in ${PsdElm.DeclareAnimation}: ${type}`) + } + }) + + return result +} + +const parseVoice = ( + self: AnyElement, +): VoiceNode => { + const { voice, voiceMotion, trim, fadeInFrames, fadeOutFrames, volume, showWaveform } = self.props + return { + type: PsdElm.Voice, + voice, + voiceMotion, + trim, + fadeInFrames, + fadeOutFrames, + volume: volume ?? undefined, + showWaveform, + } +} + +const parseMotion = ( + self: AnyElement, +): MotionNode => { + const { motion } = self.props + return { + type: PsdElm.Motion, + motion + } +} + + +const getDslType = (el: AnyElement): string | undefined => { + const type = el.type as any + + if (type?.__dslType) { + return type.__dslType + } + if (typeof type === "function") { + return "function" + } + + return undefined +} diff --git a/src/lib/character/character-unit/psd-character-component.tsx b/src/lib/character/character-unit/psd-character-component.tsx new file mode 100644 index 0000000..cdd88e6 --- /dev/null +++ b/src/lib/character/character-unit/psd-character-component.tsx @@ -0,0 +1,246 @@ +import type { ReactNode } from "react" +import type { Variable, VariableType, AnimationContext } from "../../animation" +import type { Trim } from "../../trim" +import { defineDSL } from "../utils/defineDSL" +import { PsdCharacterElement } from "./ast" +import type { AudioSegment } from "../../audio-plan" +import type { WaveformData } from "../../audio-waveform" +import type { Entries, TypedRecord, Variables } from "../utils/util-types" + +/** + * Serialize children elements in sequence. + * Behaves similarly to a Sequence component. + * + * 子要素を直列化する。 + * Sequenceと同等のはたらきをする。 + */ +export const MotionSequence = defineDSL<{ + children: React.ReactNode +}>(PsdCharacterElement.MotionSequence) + +type DeclareVariableProps = { + variableName: T + initValue: U + children: React.ReactNode +} + +/** + * Declare a Variable. + * @template T Union of string literal variable names + * @template U Variable type (one of VariableType) + * @param variableName Name of the variable + * @param initValue Initial value of the variable (used in useVariable) + * + * Variableを宣言する。 + * @template T 変数名の文字列リテラルのUnion + * @template U 変数の型。VariableTypeの一つ + * @param variableName 変数名 + * @param initValue 変数の初期値。useVariableで指定するもの + */ +export const DeclareVariable = (_: DeclareVariableProps) => null + +DeclareVariable.__dslType = PsdCharacterElement.DeclareVariable + +/** + * Used directly under MotionSequence to parallelize children elements. + * + * MotionSequence直下で使用し、子要素を並列化する + */ +export const MotionClip = defineDSL<{ + children: React.ReactNode +}>(PsdCharacterElement.MotionClip) + +type DeclareAnimationProps = { + animation: (ctx: AnimationContext, variables: Record>) => Promise + children: React.ReactNode +} + +/** + * Register declared variables as an animation. + * @template T Union of variable names to initialize + * @param animation Function that receives AnimationContext and variable record, same as useAnimation callback + * + * 宣言されたVariableをアニメーションとして登録する + * @template T 初期化する変数の変数名リテラルのUnion + * @param animation AnimationContextと変数のRecordを受け取ってアニメーションを記述する。useAnimationの第一引数と同じ + */ +export const DeclareAnimation = (_: DeclareAnimationProps) => null +DeclareAnimation.__dslType = PsdCharacterElement.DeclareAnimation + +/** + * Place audio (file-based). Same as Sound except for voice. + * @param voice Audio file path + * @param voiceMotion Function to generate animation using audio + * + * 音声を配置する(ファイルのみ)。voice以外はSoundと同様 + * @param voice 音声ファイル + * @param voiceMotion 音声を利用したアニメーションをつける関数 + */ +export const Voice = defineDSL<{ + voice: string + voiceMotion?: (segment: AudioSegment, waveform: WaveformData, variables: Record>, frames: number[]) => Record + trim?: Trim + fadeInFrames?: number + fadeOutFrames?: number + volume?: number + showWaveform?: boolean +}>(PsdCharacterElement.Voice) + +type MotionProps = { + motion: (variables: Record>, frames: number[]) => Record +} + +/** + * Control PSD options and apply motion. + * Hooks cannot be used, so values must be retrieved via frame indices (e.g., variable.name.get(frames[0])). + * @template T Union of variable names used + * @param motion Function that returns PSD option record from variables and frames + * + * frames behavior: + * frames[0] -> useCurrentFrame + * frames[last] -> useGlobalCurrentFrame + * + * psdファイルのオプションを制御し、動きをつける + * フックは使えないのでvariable.name.get(frames[0])のようにフレームを指定して受け取る + * @template T 使用する変数の変数名のリテラルのUnion + * @param motion variablesとframesを受け取ってpsdオプションのRecordを返す + * + * motionの受け取るframesは次の通り + * frames[0] useCurrentFrame + * frames[frames.length - 1] useGlobalCurrentFrame + */ +export const Motion = (_: MotionProps) => null +Motion.__dslType = PsdCharacterElement.Motion + +// complex components ------------------------ + +/** + * Convert flat variable record into strongly typed Variables. + * + * フラットな変数Recordを型付きVariablesに変換する + */ +const typeVariables = >( + flat: Record>, +): Variables => { + const result = {} as Variables + + for (const key of Object.keys(flat) as (keyof T)[]) { + result[key] = flat[key as string] as Variable + } + + return result +} + + +type DeclareVariablesProps> = { + variables: T + animation: (ctx: AnimationContext, variables: Variables) => Promise + children: ReactNode +} + +/** + * Declare multiple variables and register animation at once. + * @template T Type of declared variables + * @param variables Object-style variable declaration (e.g., {t: 0, p: {x: 0, y: 0}}) + * @param animation Animation registration callback (same as useAnimation) + * + * 変数を宣言する。 + * animationの登録も同時に行う。 + * @template T 宣言する変数の型 + * @param variables 変数をオブジェクトとして宣言する。e.g. variables: {t: 0, p: {x: 0, y: 0}} + * @param animation AnimationContextと宣言した変数を受け取って、アニメーションを登録する。useAnimationのコールバックと同様。 + */ +export const DeclareVariables = = any>(props: DeclareVariablesProps) => { + let result = + props.animation(ctx, typeVariables(variables))}> + {props.children} + + + // Wrap children with DeclareVariable in reverse order (outermost = first variable) + // 逆順でDeclareVariableをネストしてラップする(最初の変数が最外側になる) + for (const [key, value] of Object.entries(props.variables).reverse() as Entries) { + result = + + {result} + + } + + return result +} + + +type MotionWithVarsProps, T extends Record> = { + variables: TypedRecord + animation: (ctx: AnimationContext, variable: Variables) => Promise + motion: (variables: Variables, frames: number[]) => Record +} + +type VoiceMotionProps> = { + voice: string + voiceMotion: (segment: AudioSegment, waveform: WaveformData, variables: Variables, frames: number[]) => Record + trim?: Trim + fadeInFrames?: number + fadeOutFrames?: number + volume?: number + showWaveform?: boolean +} + +/** + * Perform animation driven by audio. + * Same as Voice.voiceMotion but with typed variables. + * @template T Variable types to declare + * + * 音声を利用したアニメーションを行う + * VoiceのvoiceMotionと同様だがvariablesに型をつけられる + * @template T 宣言する変数の型 + */ +export const VoiceMotion = = any>(props: VoiceMotionProps) => { + let result = + >, frames: number[]) => props.voiceMotion(segment, waveform, typeVariables(variables), frames)} + trim={props.trim} + fadeInFrames={props.fadeInFrames} + fadeOutFrames={props.fadeOutFrames} + volume={props.volume} + showWaveform={props.showWaveform} + /> + + return result +} + + +/** + * Create Motion using variables. + * Handles variable declaration, animation registration, and motion definition. + * @template S Already registered variable types + * @template T Newly declared variable types + * @param variables Variable declaration object + * @param animation Animation registration callback + * @param motion Motion definition using variables and frames + * + * 変数を利用したMotionをつくる。 + * 変数の宣言、アニメーションの登録、動きの実装を行う。 + * @template S 既にアニメーションとして登録済みの変数の型 + * @template T 宣言する変数の型 + * @param variables 変数をオブジェクトとして宣言する。e.g. variables: {t: 0, p: {x: 0, y: 0}} + * @param animation AnimationContextと宣言した変数を受け取って、アニメーションを登録する。useAnimationのコールバックと同様。 + * @param motion variablesとframesを受け取って、psdのオプションのRecordを返す。フックは使えないのでvariables.t.get(frames[0])のようにして変数を利用する。 + */ +export const MotionWithVars = = {}, T extends Record = Record>(props: MotionWithVarsProps) => { + let result = + props.animation(ctx, typeVariables(variables))}> + props.motion(typeVariables(variables), frames)} /> + + + // Wrap with DeclareVariable in reverse order + // DeclareVariableで逆順にラップする + for (const [key, value] of Object.entries(props.variables).reverse() as Entries) { + result = + + {result} + + } + + return result +} diff --git a/src/lib/character/character-unit/psd-character.tsx b/src/lib/character/character-unit/psd-character.tsx new file mode 100644 index 0000000..36159a8 --- /dev/null +++ b/src/lib/character/character-unit/psd-character.tsx @@ -0,0 +1,653 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" + +import { PsdCharacterElement as PsdElm, type MotionClipNode, type CharacterNode, type DeclareAnimationNode, type DeclareVariableNode, type MotionNode, type MotionSequenceNode, type VoiceNode } from "./ast" +import { readPsd, type Psd } from "ag-psd" +import { parsePsdCharacter } from "./parser" +import { renderPsd } from "ag-psd-psdtool" +import { useAnimation, useVariable, type Variable } from "../../animation" +import { useCurrentFrame, useGlobalCurrentFrame } from "../../frame" +import { Sound } from "../../sound/sound" +import { Clip, ClipSequence } from "../../clip" +import { useAudioSegments } from "../../audio-plan" +import { useWaveformBank } from "../../sound/character" + +type PsdCharacterProps = { + psd: string + className?: string + children: React.ReactNode +} + +type PsdPath = { + path: string +} + +type PsdOptions = Record + +/** + * Option register system for PSD rendering. + * Each runtime node registers its own partial options, + * which are later merged into a single PSD option object. + * + * PSD描画のためのオプション登録システム。 + * 各ノードが部分的なオプションを登録し、 + * 最終的にそれらをマージして1つのオプションにする。 + */ +type OptionRegister = () => { + update: (opt: Record) => void + getter: () => Record + unregister: () => void +} + + +/** + * Create an animation system using PSD synchronized with audio. + * Renders the PSD onto a canvas. + * + * Important: + * Hooks cannot be used inside DSL children. + * + * 音声と同期したPSDアニメーションを構築するコンポーネント。 + * canvas上にPSDを描画する。 + * + * 注意: + * DSL内部ではReactフックは使用不可 + * + * @example + * ```typescript + * + * + * + * ``` + */ +export const PsdCharacter = ({ + psd, + className, + children +}: PsdCharacterProps) => { + const [myPsd, setPsd] = useState(undefined) + const [ast, setAst] = useState(undefined) + + /** + * Registry storing per-node options. + * Key = node id, Value = partial PSD options. + * + * ノードごとのオプションを保持するレジストリ + */ + const registry = useRef(new Map()) + + /** + * Order of registration (important for layering / precedence). + * + * 登録順序(レイヤー優先度に影響) + */ + const order = useRef([]) + + /** + * Final merged options used for rendering. + * + * 描画に使われる最終的なオプション + */ + const options = useRef({}) + + const canvas = useRef(null) + + /** + * Load PSD and parse DSL into AST. + * + * PSDのロードとDSLのAST変換 + */ + useEffect(() => { + fetchPsd(normalizePsdPath(psd)).then(p => setPsd(p)) + setAst(parsePsdCharacter(children)) + }, [psd]) + + /** + * Render PSD every frame. + * + * 毎フレームPSDを描画 + */ + const frame = useCurrentFrame() + useEffect(() => { + if (typeof myPsd !== "undefined" && canvas.current) { + renderPsd(myPsd, options.current, { canvas: canvas.current }) + } + }, [frame, myPsd]) + + /** + * Merge all registered options. + * + * 登録されたオプションをマージ + */ + const recompute = useCallback(() => { + const merged = Object.assign({}, ...registry.current.values()) + options.current = merged + }, []) + + /** + * Create a new option registration slot. + * Each node uses this to contribute rendering options. + * + * 各ノードがオプションを登録するためのスロットを作成 + */ + const register = useCallback(() => { + const id = crypto.randomUUID() + + registry.current.set(id, {}) + order.current.push(id) + + const update = (opt: PsdOptions) => { + registry.current.set(id, opt) + recompute() + } + + const unregister = () => { + registry.current.delete(id) + order.current = order.current.filter(x => x !== id) + recompute() + } + + /** + * Get accumulated options before this node. + * Used for layered evaluation. + * + * 自分より前に登録されたオプションを取得 + */ + const getter = () => { + const index = order.current.indexOf(id) + const prevIds = order.current.slice(0, index) + const prevOptions = prevIds.map(i => registry.current.get(i) ?? {}) + return Object.assign({}, ...prevOptions) + } + + return { + update, + getter, + unregister, + } + }, []) + + return ( + <> + + + {/* Execute AST nodes */} + {/* ASTノードを実行 */} + {ast?.children.map((child, i) => { + switch (child.type) { + case PsdElm.MotionSequence: + return + case PsdElm.DeclareVariable: + return + case PsdElm.Voice: + return + case PsdElm.Motion: + return + default: + return null + } + })} + + ) +} + +type MotionSequenceRuntimeProps = { + ast: MotionSequenceNode + variables: Record> + register: OptionRegister +} + +const MotionSequenceRuntime = ({ + ast, + variables, + register +}: MotionSequenceRuntimeProps) => { + const reg = useRef>(undefined) + if (!reg.current) { + reg.current = register() + } + const {update, getter, unregister} = reg.current + + useEffect(() => { + return () => unregister() + }, []) + + // 直列のため同じregisterを使う + const curRegister: OptionRegister = useCallback(() => { + return {update, getter, unregister: () => {}} + }, []) + + return ( + + {ast.children.map(child => { + switch (child.type) { + case PsdElm.DeclareVariable: + return + case PsdElm.MotionClip: + return + case PsdElm.Voice: + return + case PsdElm.Motion: + return + default: + return null + } + }).map((child, i) => {child} )} + + ) +} + +type DeclareVariableRuntimeProps = { + ast: DeclareVariableNode + variables: Record> + initializingVariables: Record> + register: OptionRegister +} + +const DeclareVariableRuntime = ({ + ast, + variables, + initializingVariables, + register +}: DeclareVariableRuntimeProps) => { + // T extends VariableTypeとして + // DeclareVariableで受け取る型がTなので + // ast.initValue: T + // であり、これを使う限り問題ない + const variable = useVariable(ast.initValue) + const newInitVariables = {[ast.variableName]: variable, ...initializingVariables} + + switch (ast.children.type) { + case PsdElm.DeclareVariable: + return + case PsdElm.DeclareAnimation: + return + default: + return null + } +} + +type MotionClipRuntimeProps = { + ast: MotionClipNode + variables: Record> + register: OptionRegister +} + +const MotionClipRuntime = ({ + ast, + variables, + register +}: MotionClipRuntimeProps) => { + const reg = useRef>(undefined) + if (!reg.current) { + reg.current = register() + } + const {update, getter: superGetter, unregister} = reg.current + + useEffect(() => { + return () => unregister() + }, []) + + const curRegistry = useRef(new Map()) + const order = useRef([]) + + const options = useRef({}) + + const recompute = useCallback(() => { + const merged = Object.assign({}, ...curRegistry.current.values()) + options.current = merged + }, []) + + const curRegister = useCallback(() => { + const id = crypto.randomUUID() + + curRegistry.current.set(id, {}) + order.current.push(id) + + const update = (opt: PsdOptions) => { + curRegistry.current.set(id, opt) + recompute() + } + + const unregister = () => { + curRegistry.current.delete(id) + order.current = order.current.filter(x => x !== id) + recompute() + } + + const getter = () => { + const index = order.current.indexOf(id) + + const prevIds = order.current.slice(0, index) + + const prevOptions = prevIds.map(i => curRegistry.current.get(i) ?? {}) + + return Object.assign(superGetter(), ...prevOptions) + } + + return { + update, + getter, + unregister, + } + }, []) + + const frame = useCurrentFrame() + useEffect(() => { + update(options.current) + }, [frame]) + + + return ( + <> + {ast.children.map((child, i) => { + switch (child.type) { + case PsdElm.MotionSequence: + return + case PsdElm.DeclareVariable: + return + case PsdElm.Voice: + return + case PsdElm.Motion: + return + default: + return null + } + })} + + ) +} + +type DeclareAnimationRuntimeProps = { + ast: DeclareAnimationNode + variables: Record> + initializingVariables: Record> + register: OptionRegister +} + +const DeclareAnimationRuntime = ({ + ast, + variables, + initializingVariables, + register +}: DeclareAnimationRuntimeProps) => { + + useAnimation(async (ctx) => { + await ast.animation(ctx, initializingVariables) + }, []) + + const curVariables = {...variables, ...initializingVariables} + + const reg = useRef>(undefined) + if (!reg.current) { + reg.current = register() + } + const {update, getter: superGetter, unregister} = reg.current + + useEffect(() => { + return () => unregister() + }, []) + + const curRegistry = useRef(new Map()) + const order = useRef([]) + + const options = useRef({}) + + const recompute = useCallback(() => { + const merged = Object.assign({}, ...curRegistry.current.values()) + options.current = merged + }, []) + + const curRegister = useCallback(() => { + const id = crypto.randomUUID() + + curRegistry.current.set(id, {}) + order.current.push(id) + + const update = (opt: PsdOptions) => { + curRegistry.current.set(id, opt) + recompute() + } + + const unregister = () => { + curRegistry.current.delete(id) + order.current = order.current.filter(x => x !== id) + recompute() + } + + const getter = () => { + const index = order.current.indexOf(id) + + const prevIds = order.current.slice(0, index) + + const prevOptions = prevIds.map(i => curRegistry.current.get(i) ?? {}) + + return Object.assign(superGetter(), ...prevOptions) + } + + return { + update, + getter, + unregister, + } + }, []) + + const frame = useCurrentFrame() + useEffect(() => { + update(options.current) + }, [frame]) + + return ( + <> + {ast.children.map((child, i) => { + switch (child.type) { + case PsdElm.MotionSequence: + return + case PsdElm.DeclareVariable: + return + case PsdElm.Voice: + return + case PsdElm.Motion: + return + default: + return null + } + })} + + ) +} + +type VoiceRuntimeProps = { + ast: VoiceNode, + variables: Record> + register: OptionRegister +} + +const VoiceRuntime = (props: VoiceRuntimeProps) => { + return +} + +const VoiceRuntimeInner = ({ + ast, + variables, + register +}: VoiceRuntimeProps) => { + const reg = useRef>(undefined) + if (!reg.current) { + reg.current = register() + } + const { update, getter, unregister } = reg.current + + useEffect(() => { + return () => unregister() + }, []) + + const localFrame = useCurrentFrame() + const globalFrame = useGlobalCurrentFrame() + const frames = [localFrame, globalFrame] + const audioSegments = useAudioSegments() + const audioSegment = useMemo(() => { + return audioSegments.filter(seg => seg.source.path == ast.voice).at(0) + }, [ast, audioSegments]) + const waveformData = useWaveformBank([ast.voice]) + + useEffect(() => { + if (audioSegment && ast.voiceMotion) { + update(ast.voiceMotion(audioSegment, waveformData.get(ast.voice) ?? null, variables, frames)) + } + }, [localFrame, audioSegment, waveformData]) + + const volume = + typeof ast.volume === "function" + ? ast.volume(variables, frames) + : ast.volume + + return ( + + ) +} + +type MotionRuntimeProps = { + ast: MotionNode, + variables: Record> + register: OptionRegister +} + +const MotionRuntime = ({ + ast, + variables, + register +}: MotionRuntimeProps) => { + const reg = useRef>(undefined) + if (!reg.current) { + reg.current = register() + } + const { update, getter, unregister } = reg.current + + useEffect(() => { + return () => unregister() + }, []) + + const localTime = useCurrentFrame() + const globalTime = useGlobalCurrentFrame() + + useEffect(() => { + update(ast.motion(variables, [localTime, globalTime])) + }, [localTime]) + + return null +} + + +const psdCache = new Map() +const psdPending = new Map>() + +const fetchPsd = async (psd: PsdPath): Promise => { + const cached = psdCache.get(psd.path) + if (cached != null) return cached + + const pending = psdPending.get(psd.path) + if (pending) return pending + + const next = (async () => { + const res = await fetch(buildPsdUrl(psd)) + if (!res.ok) { + throw new Error("failed to fetch psd file") + } + + const file = readPsd(await res.arrayBuffer()) + psdCache.set(psd.path, file) + return file + })().finally(() => { + psdPending.delete(psd.path) + }) + + psdPending.set(psd.path, next) + return next +} + +const normalizePsdPath = (psd: PsdPath | string): PsdPath => { + if (typeof psd === "string") return { path: psd } + return psd +} + +const buildPsdUrl = (pad: PsdPath) => { + const url = new URL("http://localhost:3000/file") + url.searchParams.set("path", pad.path) + return url.toString() +} diff --git a/src/lib/character/character-unit/util-motions.tsx b/src/lib/character/character-unit/util-motions.tsx new file mode 100644 index 0000000..ecfdc4b --- /dev/null +++ b/src/lib/character/character-unit/util-motions.tsx @@ -0,0 +1,496 @@ +import { PROJECT_SETTINGS } from "../../../../project/project" +import { framesToSeconds } from "../../audio" +import { DEFAULT_THRESHOLD, resolveSegmentAmplitude } from "../../sound/character" +import { Motion, VoiceMotion } from "./psd-character-component" +import type { Trim } from "../../trim" + +// ================================ +// 型定義(PSDパーツの指定方法) +// ================================ + +/** + * Defines basic options for controlling eye and mouth animations. + * Used to configure how PSD layers are switched during animation. + * + * 目と口のアニメーション制御に使用する基本設定。 + * PSDのレイヤー切り替え方法を定義するために使う。 + */ +export type BasicPsdOptions = { + eye: EyeOptions4 + mouth: MouthOptions +} + +/** + * Supports either enum-based switching or boolean layer toggling. + * + * enum形式(1つ選択)またはbool形式(ON/OFF切り替え)のどちらでも扱えるようにする + */ +export type EyeOptions4 = + | { kind: "enum"; options: EyeEnum } + | { kind: "bool"; options: EyeBool } + +export type MouthOptions = + | { kind: "enum"; options: MouthEnum } + | { kind: "bool"; options: MouthBool } + +export type SimpleMouthOptions = + | { kind: "enum"; options: MouthEnum } + | { kind: "bool"; options: MouthBool } + +/** + * 目の状態(4段階) + */ +export type EyeShape4 = "Open" | "HalfOpen" | "HalfClosed" | "Closed" + +/** + * Enum-style eye configuration. + * Specifies a single active layer from multiple options. + * + * enum形式の目指定。 + * 複数のレイヤーから1つを選択して切り替える。 + */ +export type EyeEnum = { + Eye: string + Default: string +} & Record + +/** + * Boolean-style eye configuration. + * Turns layers on/off individually. + * + * bool形式の目指定(レイヤーON/OFF制御) + */ +export type EyeBool = { + Default: string +} & Record + +/** + * 口の形(母音ベース) + */ +export type MouthShapeVowel = "A" | "I" | "U" | "E" | "O" | "X" +/** + * 口の形(音量ベース) + */ +export type MouthShape2 = "Open" | "Closed" + +/** + * Enum-style mouth configuration. + * + * enum形式の口指定 + */ +export type MouthEnum = { + Mouth: string + Default: string +} & Record + +/** + * Boolean-style mouth configuration. + * + * bool形式の口指定 + */ +export type MouthBool = { + Default: string +} & Record + +/** + * Utility type to enforce required keys. + * + * 指定したキーを必須にするユーティリティ型 + */ +type HasKey = { + [P in K]: V +} & Record + + +// ================================ +// LipSync(音素ベース口パク) +// ================================ + +export type LipSyncData = HasKey<"mouthCues", {start: number, end: number, value: string}[]> + +export type LipSyncProps = { + data: LipSyncData +} + +/** + * Creates a lip-sync component based on phoneme timing data. + * The mouth shape is automatically selected depending on the current time. + * + * 音素タイミングデータをもとに口パクを行うコンポーネントを生成する。 + * 現在の時間に応じて口の形が自動で切り替わる。 + * + * @example + * ```typescript + * const LipSync = createLipSync({ + * kind: "enum" as const, + * options: { + * Mouth: "目・口/口", + * Default: "あ", + * A: "あ", + * I: "い", + * U: "う", + * E: "え", + * O: "お", + * X: "閉じ", + * } + * }) + * + * const data = { + * mouthCues: [{start: 0}, {end: 1}, {value: "A"}] + * } + * + * // 略 -------------------- + * + * + * + * + * + * ``` + */ +export const createLipSync = (mouthOptions: MouthOptions, value2option: Record = rhubarbTable) => { + return ({ data }: LipSyncProps) => { + return { + + // 現在フレームを秒に変換 + const t = framesToSeconds(frames[0]) + + let shape: MouthShapeVowel | undefined = undefined + + // 現在時刻に該当するセクションを線形探索 + for (let section of data.mouthCues) { + if (section.start <= t && t < section.end) { + shape = value2option[section.value] ?? "X" + break + } + } + + // 該当なし → 何も変更しない + if (!shape) { + return {} + } + + // PSDのレイヤー指定に変換 + return applyOption(mouthOptions, shape) + }} /> + } +} + +/** + * 音素ラベル → 母音口形に変換 + * (rhubarbのフォーマットに対応) + */ +const rhubarbTable: Record = { + "A": "A", + "B": "I", + "C": "E", + "D": "A", + "E": "O", + "F": "U", + "G": "I", + "H": "U", + "X": "X", +} + + +// ================================ +// Simple LipSync(音量ベース) +// ================================ + +type SimpleLipSyncProps = { + voice: string, + threshold?: number + trim?: Trim + fadeInFrames?: number + fadeOutFrames?: number + volume?: number + showWaveform?: boolean +} + +/** + * Creates a simple lip-sync based on audio volume. + * Mouth opens when volume exceeds threshold. + * + * 音量に応じて口を開閉するシンプルな口パク。 + * 一定以上の音量で口が開く。 + * + * @example + * ```typescript + * const LipSync = createSimpleLipSync({ + * kind: "enum" as const, + * options: { + * Mouth: "目・口/口", + * Default: "あ", + * Open: "あ", + * Closed: "閉じ", + * } + * }) + * + * // 略 -------------------- + * + * + * + * + * ``` + */ +export const createSimpleLipSync = (mouthOptions: SimpleMouthOptions) => { + return ({ + voice, + threshold = DEFAULT_THRESHOLD, + trim, + fadeInFrames, + fadeOutFrames, + volume, + showWaveform + }: SimpleLipSyncProps) => { + return { + + // 現在フレーム時点の音量を取得 + const amp = resolveSegmentAmplitude( + audioSegment, + waveform, + frames[frames.length - 1], + PROJECT_SETTINGS.fps + ) + + // 閾値で開閉を切り替え + return amp > threshold + ? applyOption(mouthOptions, "Open") + : applyOption(mouthOptions, "Closed") + }} + trim={trim} + fadeInFrames={fadeInFrames} + fadeOutFrames={fadeOutFrames} + volume={volume} + showWaveform={showWaveform} + /> + } +} + + +// ================================ +// Blink(目パチ) +// ================================ + +export type BlinkData = HasKey<"blinkCues", {start: number, end: number, value: string}[]> + +export type BlinkProps = { + data: BlinkData +} + +/** + * Creates a blink animation based on timing data. + * + * タイミングデータに基づいて目パチを行う。 + * + * @example + * ```typescript + * const Blink = createBlink({ + * kind: "enum" as const, + * options: { + * Eye: "目・口/目", + * Default: "デフォルト" + * Open: "デフォルト", + * HalfOpen: "やや閉じ", + * HalfClosed: "半目", + * Closed: "閉じ" + * } + * }) + * + * const data = { + * blinkCues: [ + * { start: 0.00, end: 0.40, value: "A" }, + * { start: 0.40, end: 0.45, value: "B" }, + * { start: 0.45, end: 0.50, value: "C" }, + * { start: 0.50, end: 0.55, value: "D" }, + * { start: 0.55, end: 0.60, value: "C" }, + * { start: 0.60, end: 0.65, value: "B" }, + * { start: 0.65, end: 6.65, value: "A" } + * ] + * } + * + * // 略 -------------------- + * + * + * + * + * + * ``` + */ +export const createBlink = (eyeOptions: EyeOptions4) => { + return ({ data }: BlinkProps) => { + return { + + const t = framesToSeconds(frames[1]) + const sections = data.blinkCues + + // ========================= + // 二分探索(start <= t の最大index) + // LipSyncよりも長くなることが多いと想定し線形探索でなく二分探索 + // ========================= + let lo = 0 + let hi = sections.length - 1 + let idx = -1 + + while (lo <= hi) { + const mid = (lo + hi) >> 1 + + if (sections[mid].start <= t) { + idx = mid + lo = mid + 1 + } else { + hi = mid - 1 + } + } + + let shape: EyeShape4 | undefined = undefined + + // 範囲内なら有効 + if (idx !== -1 && t < sections[idx].end) { + shape = BlinkValueToEyeShape(sections[idx].value) + } + + if (!shape) { + return {} + } + + return applyOption(eyeOptions, shape) + }} /> + } +} + +/** + * Blink用の値 → 目の状態に変換 + */ +const BlinkValueToEyeShape = (value: string): EyeShape4 => { + switch (value) { + case "A": return "Open" + case "B": return "HalfOpen" + case "C": return "HalfClosed" + case "D": return "Closed" + default: return "Open" + } +} + +export const generateBlinkData = (start: number, end: number): BlinkData => { + const unit = (t: number) => [ + { start: t + 0.00, end: t + 0.01, value: "A" }, + { start: t + 0.01, end: t + 0.02, value: "B" }, + { start: t + 0.02, end: t + 0.04, value: "C" }, + { start: t + 0.04, end: t + 0.06, value: "D" }, + { start: t + 0.06, end: t + 0.08, value: "C" }, + { start: t + 0.08, end: t + 0.09, value: "B" }, + ] + + const unitDuration = 0.09 + + let t = start + let lastEnd = start + + const data: { start: number; end: number; value: string }[] = [] + + while (t < end) { + // ランダム間隔 + const interval = 2 + Math.random() * 3 + t += interval + + // unitがはみ出るなら終了 + if (t + unitDuration > end) break + + // 空白時間をAで埋める + if (lastEnd < t) { + data.push({ + start: lastEnd, + end: t, + value: "A", + }) + } + + const blink = unit(t) + data.push(...blink) + + lastEnd = t + unitDuration + t = lastEnd + } + + // 最後の余りもAで埋める + if (lastEnd < end) { + data.push({ + start: lastEnd, + end: end, + value: "A", + }) + } + + return { + blinkCues: data, + } +} + + +// ================================ +// 共通:PSDレイヤー適用ロジック +// ================================ + +function applyOption(optionDict: EyeOptions4, option: EyeShape4): Record; +function applyOption(optionDict: MouthOptions, option: MouthShapeVowel): Record; +function applyOption(optionDict: SimpleMouthOptions, option: MouthShape2): Record; + +/** + * option(状態)をPSDレイヤー指定に変換する + * + * enum: + * → "パス": "レイヤー名" + * + * bool: + * → DefaultをOFF/ONしつつ対象レイヤーをtrueにする + */ +function applyOption( + optionDict: EyeOptions4 | MouthOptions | SimpleMouthOptions, + option: EyeShape4 | MouthShapeVowel | MouthShape2 +): Record { + + // 未定義のキーは無視 + if (!(option in optionDict.options)) return {} + + const opt = option as keyof typeof optionDict.options + + // ========================= + // enum形式 + // ========================= + if (optionDict.kind == "enum") { + + // 口 + if ("Mouth" in optionDict.options) { + return { + [optionDict.options.Mouth]: optionDict.options[opt] + } + } + + // 目 + if ("Eye" in optionDict.options) { + return { + [optionDict.options.Eye]: optionDict.options[opt] + } + } + + throw "unknown type dict" + } + + // ========================= + // bool形式(レイヤーON/OFF) + // ========================= + if (optionDict.options[opt] == optionDict.options.Default) { + // デフォルト状態 + return { + [optionDict.options.Default]: true + } + } else { + // 対象ON + デフォルトOFF + return { + [optionDict.options.Default]: false, + [optionDict.options[opt]]: true + } + } +} diff --git a/src/lib/character/utils/defineDSL.ts b/src/lib/character/utils/defineDSL.ts new file mode 100644 index 0000000..274a667 --- /dev/null +++ b/src/lib/character/utils/defineDSL.ts @@ -0,0 +1,15 @@ +import type { ReactElement } from "react" + +export type DslComponent

= { + (props: P): ReactElement | null + __dslType: string +} + +/** + * DSL向けに__dslTypeプロパティを持つコンポーネントを作成する + */ +export const defineDSL =

(type: string): DslComponent

=> { + const C = ((_: P) => null) as DslComponent

+ C.__dslType = type + return C +} diff --git a/src/lib/character/utils/util-types.ts b/src/lib/character/utils/util-types.ts new file mode 100644 index 0000000..f0a5f5f --- /dev/null +++ b/src/lib/character/utils/util-types.ts @@ -0,0 +1,15 @@ +import type { Variable, VariableType } from "../../animation"; + + +export type OneOrMany = T | T[] + +export type Entries = [keyof T, T[keyof T]][]; + +export type TypedRecord> = { + [K in keyof T]: T[K] +} + +export type Variables> = { + [K in keyof T]: Variable +} + diff --git a/src/lib/sound/character.tsx b/src/lib/sound/character.tsx index d7aafb0..ad781ba 100644 --- a/src/lib/sound/character.tsx +++ b/src/lib/sound/character.tsx @@ -20,12 +20,12 @@ export type CharacterProps = { alt?: string } -const DEFAULT_THRESHOLD = 0.1 +export const DEFAULT_THRESHOLD = 0.1 const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)) -const useWaveformBank = (paths: string[]) => { +export const useWaveformBank = (paths: string[]) => { const [bank, setBank] = useState>(new Map()) const { key, list } = useMemo(() => { @@ -54,7 +54,7 @@ const useWaveformBank = (paths: string[]) => { return bank } -const resolveSegmentAmplitude = ( +export const resolveSegmentAmplitude = ( segment: AudioSegment, waveform: WaveformData | null, currentFrame: number,