Skip to content

Commit d6ddd2f

Browse files
committed
add SpeedLines component
1 parent cada936 commit d6ddd2f

File tree

6 files changed

+206
-1
lines changed

6 files changed

+206
-1
lines changed

docs/docs/lib/animation.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,18 @@ useAnimation(async (ctx) => {
7171
```
7272

7373
The key is that you can start motions early and await them later. That makes it easy to layer animations without complex bookkeeping.
74+
75+
## Effects: SpeedLines
76+
77+
`<SpeedLines />` is a focused-line overlay that reacts to the current frame and adds a subtle jitter.
78+
79+
```tsx
80+
import { SpeedLines } from "../src/lib/animation/effect/speed-lines"
81+
import { FillFrame } from "../src/lib/layout/fill-frame"
82+
83+
const Impact = () => (
84+
<FillFrame>
85+
<SpeedLines />
86+
</FillFrame>
87+
)
88+
```

docs/docs/lib/media.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,23 @@ For longer clips, set `showWaveform` to enable it explicitly:
5151
```tsx
5252
<Sound sound="assets/music.mp3" showWaveform />
5353
```
54+
55+
### `<Character>`
56+
57+
Switches between closed/open mouth images based on audio loudness.
58+
If `clipLabel` is provided, it only reacts to audio inside clips with that label.
59+
60+
```tsx
61+
import { Character } from "../src/lib/sound/character"
62+
63+
<Clip label="Voice">
64+
<Sound sound="assets/voice.mp3" />
65+
</Clip>
66+
67+
<Character
68+
mouthClosed="assets/char_closed.png"
69+
mouthOpen="assets/char_open.png"
70+
threshold={0.12}
71+
clipLabel="Voice"
72+
/>
73+
```

docs/i18n/ja/docusaurus-plugin-content-docs/current/lib/animation.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,18 @@ useAnimation(async (ctx) => {
7171
```
7272

7373
ポイントは「先に動かしておいて、あとで await する」ことができる点です。これにより、同期的に順番通りに実行するだけでなく、簡単に重ね合わせ(並行演出)も作れます。
74+
75+
## Effects: SpeedLines
76+
77+
`<SpeedLines />` は集中線のオーバーレイで、フレームに合わせて微妙に揺れます。
78+
79+
```tsx
80+
import { SpeedLines } from "../src/lib/animation/effect/speed-lines"
81+
import { FillFrame } from "../src/lib/layout/fill-frame"
82+
83+
const Impact = () => (
84+
<FillFrame>
85+
<SpeedLines />
86+
</FillFrame>
87+
)
88+
```

docs/i18n/ja/docusaurus-plugin-content-docs/current/lib/media.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,23 @@ import { Sound } from "../src/lib/sound/sound"
4949
```tsx
5050
<Sound sound="assets/music.mp3" showWaveform />
5151
```
52+
53+
### `<Character>`
54+
55+
音量に応じて口の閉じた/開いた画像を切り替えます。
56+
`clipLabel` を指定すると、そのラベルのクリップ内の音声にのみ反応します。
57+
58+
```tsx
59+
import { Character } from "../src/lib/sound/character"
60+
61+
<Clip label="Voice">
62+
<Sound sound="assets/voice.mp3" />
63+
</Clip>
64+
65+
<Character
66+
mouthClosed="assets/char_closed.png"
67+
mouthOpen="assets/char_open.png"
68+
threshold={0.12}
69+
clipLabel="Voice"
70+
/>
71+
```
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { seconds, useCurrentFrame } from "../../frame"
2+
3+
/**
4+
* Speed-line overlay (concentric radial rays with jitter).
5+
*
6+
* 集中線のオーバーレイ。
7+
*
8+
* @example
9+
* ```tsx
10+
* import { SpeedLines } from "../src/lib/animation/effect/speed-lines"
11+
*
12+
* <SpeedLines />
13+
* ```
14+
*/
15+
export const SpeedLines = () => {
16+
const frame = useCurrentFrame()
17+
const step = Math.floor(frame / 2)
18+
const fade = Math.min(1, Math.max(0, frame / seconds(0.2)))
19+
const opacity = fade * 0.78
20+
const rand = (seed: number) => {
21+
const x = Math.sin(seed * 97.13 + step * 41.37) * 43758.5453123
22+
return x - Math.floor(x)
23+
}
24+
const jitter = (seed: number) => rand(seed) * 2 - 1
25+
const driftA = { x: jitter(1.1) * 22, y: jitter(2.2) * 22 }
26+
const driftB = { x: jitter(3.3) * 18, y: jitter(4.4) * 18 }
27+
const scaleA = 1.02 + jitter(5.5) * 0.05
28+
const scaleB = 1.0 + jitter(6.6) * 0.05
29+
const rotA = jitter(7.7) * 24
30+
const rotB = jitter(8.8) * 22
31+
const rotC = jitter(9.9) * 18
32+
const rotD = jitter(10.1) * 28
33+
const rayMask =
34+
"radial-gradient(circle, rgba(0,0,0,0) 0%, rgba(0,0,0,0) 22%, rgba(0,0,0,1) 34%, rgba(0,0,0,1) 72%, rgba(0,0,0,0) 88%)"
35+
36+
return (
37+
<>
38+
<div
39+
style={{
40+
position: "absolute",
41+
left: "50%",
42+
top: "50%",
43+
width: "200%",
44+
height: "200%",
45+
transform: `translate(-50%, -50%) translate(${driftA.x}px, ${driftA.y}px) rotate(${rotA}deg) scale(${scaleA})`,
46+
transformOrigin: "center",
47+
opacity,
48+
backgroundImage:
49+
"repeating-conic-gradient(from 1deg, rgba(255, 255, 255, 0.9) 0deg 0.6deg, rgba(255, 255, 255, 0) 0.6deg 18deg)",
50+
maskImage: rayMask,
51+
WebkitMaskImage: rayMask,
52+
mixBlendMode: "screen",
53+
pointerEvents: "none",
54+
}}
55+
/>
56+
<div
57+
style={{
58+
position: "absolute",
59+
left: "50%",
60+
top: "50%",
61+
width: "185%",
62+
height: "185%",
63+
transform: `translate(-50%, -50%) translate(${driftB.x}px, ${driftB.y}px) rotate(${rotB}deg) scale(${scaleB})`,
64+
transformOrigin: "center",
65+
opacity: opacity * 0.75,
66+
backgroundImage:
67+
"repeating-conic-gradient(from 7deg, rgba(255, 255, 255, 0.75) 0deg 0.6deg, rgba(255, 255, 255, 0) 0.6deg 20deg)",
68+
maskImage: rayMask,
69+
WebkitMaskImage: rayMask,
70+
mixBlendMode: "screen",
71+
pointerEvents: "none",
72+
}}
73+
/>
74+
<div
75+
style={{
76+
position: "absolute",
77+
left: "50%",
78+
top: "50%",
79+
width: "210%",
80+
height: "210%",
81+
transform: `translate(-50%, -50%) translate(${-driftA.x * 0.6}px, ${driftA.y * 0.6}px) rotate(${rotC}deg) scale(${scaleA * 1.02})`,
82+
transformOrigin: "center",
83+
opacity: opacity * 0.6,
84+
backgroundImage:
85+
"repeating-conic-gradient(from 3deg, rgba(255, 255, 255, 0.7) 0deg 0.5deg, rgba(255, 255, 255, 0) 0.5deg 22deg)",
86+
maskImage: rayMask,
87+
WebkitMaskImage: rayMask,
88+
mixBlendMode: "screen",
89+
pointerEvents: "none",
90+
}}
91+
/>
92+
<div
93+
style={{
94+
position: "absolute",
95+
left: "50%",
96+
top: "50%",
97+
width: "175%",
98+
height: "175%",
99+
transform: `translate(-50%, -50%) translate(${-driftB.x * 0.5}px, ${driftB.y * 0.5}px) rotate(${rotD}deg) scale(${scaleB * 0.98})`,
100+
transformOrigin: "center",
101+
opacity: opacity * 0.55,
102+
backgroundImage:
103+
"repeating-conic-gradient(from 11deg, rgba(255, 255, 255, 0.8) 0deg 0.6deg, rgba(255, 255, 255, 0) 0.6deg 24deg)",
104+
maskImage: rayMask,
105+
WebkitMaskImage: rayMask,
106+
mixBlendMode: "screen",
107+
pointerEvents: "none",
108+
}}
109+
/>
110+
</>
111+
)
112+
}

src/lib/sound/character.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import { type AudioSegment, useAudioSegments } from "../audio-plan"
55
import { loadWaveformData, type WaveformData } from "../audio-waveform"
66
import { useTimelineClips } from "../timeline"
77

8-
type CharacterProps = {
8+
/**
9+
* Character mouth-flap props driven by audio amplitude.
10+
*
11+
* キャラの口パク用 props。
12+
*/
13+
export type CharacterProps = {
914
mouthClosed: string
1015
mouthOpen: string
1116
threshold?: number
@@ -90,6 +95,24 @@ const resolveSegmentAmplitude = (
9095
return amplitude
9196
}
9297

98+
/**
99+
* Switches between closed/open mouth images based on audio loudness.
100+
*
101+
* 音量に応じて口の閉じた/開いた画像を切り替えます。
102+
*
103+
* @example
104+
* ```tsx
105+
* <Clip label="Voice">
106+
* <Sound sound="assets/voice.mp3" />
107+
* </Clip>
108+
* <Character
109+
* mouthClosed="assets/char_closed.png"
110+
* mouthOpen="assets/char_open.png"
111+
* threshold={0.12}
112+
* clipLabel="Voice"
113+
* />
114+
* ```
115+
*/
93116
export const Character = ({
94117
mouthClosed,
95118
mouthOpen,

0 commit comments

Comments
 (0)