@vibecuting/video-project-core 0.1.30 → 0.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@vibecuting/video-project-core",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
4
4
  "type": "module",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "main": "src/index.ts",
7
7
  "types": "src/index.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": "./src/index.ts",
10
+ "./storybook": "./src/storybook/index.ts",
11
+ "./storybook/remotion-preview": "./src/storybook/remotion-preview.tsx",
12
+ "./lint": "./src/lint/index.ts"
10
13
  },
11
14
  "publishConfig": {
12
15
  "registry": "https://registry.npmjs.org",
@@ -22,7 +25,7 @@
22
25
  "test": "pnpm exec vitest run"
23
26
  },
24
27
  "dependencies": {
25
- "@vibecuting/component-project-helper": "0.1.30",
28
+ "@vibecuting/component-project-helper": "0.1.31",
26
29
  "@remotion/transitions": "4.0.473",
27
30
  "mermaid": "^11.12.2",
28
31
  "react": "^19.0.0",
@@ -79,7 +79,7 @@ export const VideoBackgroundAudioLayer = ({
79
79
  >
80
80
  <Audio
81
81
  src={resolveVideoLayerAssetSource(cue.source)}
82
- volume={(frame) => getVideoLayerCueVolume(cue, frame)}
82
+ volume={(frame: number) => getVideoLayerCueVolume(cue, frame)}
83
83
  />
84
84
  </Sequence>
85
85
  ))}
@@ -123,7 +123,6 @@ export const VideoVoiceCaptionsForeground = ({
123
123
  key={`${caption.startMs}-${index}`}
124
124
  from={startFrame}
125
125
  durationInFrames={durationInFrames}
126
- layout="none"
127
126
  >
128
127
  <div
129
128
  style={{
@@ -89,7 +89,7 @@ export const Default3DVRPhotoScene = VideoComponent(componentMetadata)(
89
89
  const { fps } = useVideoConfig()
90
90
  const portrait = runtime.height > runtime.width
91
91
  const animated = sceneConfig.sceneType === 'animation'
92
- const entrance = sceneConfig.sceneType === 'animation' ? spring({ frame, fps, delay: 4, config: { damping: 18, mass: 0.8, stiffness: 120 } }) : 1
92
+ const entrance = sceneConfig.sceneType === 'animation' ? spring({ frame: Math.max(0, frame - 4), fps, config: { damping: 18, mass: 0.8, stiffness: 120 } }) : 1
93
93
  const orbit = animated ? Math.sin((frame / fps) * Math.PI * 0.55) : 0
94
94
  const cameraYaw = yaw + orbit * 4 + (1 - entrance) * (portrait ? -3 : -4)
95
95
  const cameraPitch = pitch + Math.sin((frame / fps) * Math.PI * 0.35) * 1.5 + (1 - entrance) * 1.6
@@ -149,7 +149,7 @@ export const DefaultEffectsPhotoScene = VideoComponent(componentMetadata)(
149
149
  config: { damping: 22, mass: 0.8, stiffness: 130 },
150
150
  })
151
151
  : 1
152
- const reveal = sceneConfig.sceneType === 'animation' ? spring({ frame, fps, delay: 6, config: { damping: 20, mass: 0.7, stiffness: 150 } }) : 1
152
+ const reveal = sceneConfig.sceneType === 'animation' ? spring({ frame: Math.max(0, frame - 6), fps, config: { damping: 20, mass: 0.7, stiffness: 150 } }) : 1
153
153
  const focusDrift = sceneConfig.sceneType === 'animation' ? Math.sin((frame / fps) * Math.PI * 0.75) : 0
154
154
  const scale = 1.01 + (1 - animationProgress) * 0.06 + focusDrift * 0.008
155
155
  const translateX = (1 - animationProgress) * (portrait ? -8 : -12) + focusDrift * (portrait ? 8 : 12)
@@ -0,0 +1,2 @@
1
+ export { lintSceneConvention } from './scene-convention-lint'
2
+ export type { SceneConventionLintOptions, SceneConventionLintResult } from './scene-convention-lint'
@@ -0,0 +1,86 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ import { describe, expect, it } from 'vitest'
6
+
7
+ import { lintSceneConvention } from './scene-convention-lint'
8
+
9
+ function writeSceneFixture(rootDir: string) {
10
+ const folder = path.join(rootDir, 'src/components/default-component-scene')
11
+ const sharedFolder = path.join(rootDir, 'src/components/_shared')
12
+ fs.mkdirSync(folder, { recursive: true })
13
+ fs.mkdirSync(sharedFolder, { recursive: true })
14
+
15
+ fs.writeFileSync(
16
+ path.join(folder, 'default-component-scene.tsx'),
17
+ [
18
+ "import { z } from 'zod'",
19
+ "export const componentMetadata = { sourceFile: 'src/components/default-component-scene/default-component-scene.tsx' }",
20
+ "export const DefaultComponentScene = defineSceneComponent({ propsSchema: z.object({ title: z.string() }), component: function DefaultComponentScene() { useRemotionSceneRuntime(); return <SceneMotionLayer /> } })",
21
+ '',
22
+ ].join('\n'),
23
+ )
24
+ fs.writeFileSync(
25
+ path.join(folder, 'default-component-scene-Landscape.tsx'),
26
+ "export const componentMetadata = { sourceFile: 'src/components/default-component-scene/default-component-scene-Landscape.tsx', aspectRatio: '16:9' }\n",
27
+ )
28
+ fs.writeFileSync(
29
+ path.join(folder, 'default-component-scene-Horizontal.tsx'),
30
+ "export const componentMetadata = { sourceFile: 'src/components/default-component-scene/default-component-scene-Horizontal.tsx', aspectRatio: '9:16' }\n",
31
+ )
32
+ fs.writeFileSync(
33
+ path.join(folder, 'default-component-scene.stories.tsx'),
34
+ "const meta = { title: 'components/default-component-scene/DefaultComponentSceneLandscape' }\nexport default meta\nexport const Default = { parameters: { scenePreviewAspectRatio: '16:9' } }\n",
35
+ )
36
+ fs.writeFileSync(
37
+ path.join(folder, 'default-component-scene.animated.stories.tsx'),
38
+ "const meta = { title: 'components/default-component-scene/DefaultComponentSceneLandscape' }\nexport default meta\nexport const Animated = { parameters: { scenePreviewAspectRatio: '16:9', scenePreviewMode: 'animation' } }\n",
39
+ )
40
+ fs.writeFileSync(
41
+ path.join(folder, 'default-component-scene-Horizontal.stories.tsx'),
42
+ "const meta = { title: 'components/default-component-scene/DefaultComponentSceneHorizontal' }\nexport default meta\nexport const Default = { parameters: { scenePreviewAspectRatio: '9:16' } }\n",
43
+ )
44
+ fs.writeFileSync(
45
+ path.join(folder, 'default-component-scene-Horizontal.animated.stories.tsx'),
46
+ "const meta = { title: 'components/default-component-scene/DefaultComponentSceneHorizontal' }\nexport default meta\nexport const Animated = { parameters: { scenePreviewAspectRatio: '9:16', scenePreviewMode: 'animation' } }\n",
47
+ )
48
+ fs.writeFileSync(path.join(folder, 'default-component-scene.test.tsx'), "import test from 'node:test'\ntest('ok', () => {})\n")
49
+ fs.writeFileSync(path.join(folder, 'index.ts'), "export * from './default-component-scene'\n")
50
+ fs.writeFileSync(path.join(sharedFolder, 'scene-frame.tsx'), 'export function SceneFrame() { return null }\n')
51
+ }
52
+
53
+ describe('scene convention lint', () => {
54
+ it('passes on the expected folder structure', () => {
55
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scene-convention-lint-'))
56
+ writeSceneFixture(rootDir)
57
+
58
+ const result = lintSceneConvention({
59
+ rootDir,
60
+ sourceRoot: 'src/components',
61
+ titleRoot: 'components',
62
+ sharedFolderName: '_shared',
63
+ })
64
+
65
+ expect(result.errors).toEqual([])
66
+ expect(result.checkedSceneFolders).toContain('src/components/default-component-scene')
67
+ })
68
+
69
+ it('reports missing animation mode', () => {
70
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scene-convention-lint-missing-animation-'))
71
+ writeSceneFixture(rootDir)
72
+ fs.writeFileSync(
73
+ path.join(rootDir, 'src/components/default-component-scene/default-component-scene.animated.stories.tsx'),
74
+ "const meta = { title: 'components/default-component-scene/DefaultComponentSceneLandscape' }\nexport default meta\nexport const Animated = { parameters: { scenePreviewAspectRatio: '16:9' } }\n",
75
+ )
76
+
77
+ const result = lintSceneConvention({
78
+ rootDir,
79
+ sourceRoot: 'src/components',
80
+ titleRoot: 'components',
81
+ sharedFolderName: '_shared',
82
+ })
83
+
84
+ expect(result.errors.some((error) => error.includes("scenePreviewMode: 'animation'"))).toBe(true)
85
+ })
86
+ })
@@ -0,0 +1,247 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ export type SceneConventionLintOptions = {
5
+ rootDir: string
6
+ sourceRoot: string
7
+ titleRoot: string
8
+ checkedFolderPrefix?: string
9
+ sharedFolderName?: string
10
+ fix?: boolean
11
+ }
12
+
13
+ export type SceneConventionLintResult = {
14
+ checkedSceneFolders: string[]
15
+ checkedStoryFiles: string[]
16
+ errors: string[]
17
+ }
18
+
19
+ const toPosix = (value: string) => value.split(path.sep).join('/')
20
+
21
+ function toRootRelative(rootDir: string, file: string) {
22
+ if (path.isAbsolute(file)) {
23
+ return toPosix(path.relative(rootDir, file))
24
+ }
25
+
26
+ return toPosix(file).replace(/^\.\//, '')
27
+ }
28
+
29
+ function resolveRootPath(rootDir: string, file: string) {
30
+ return path.resolve(rootDir, toRootRelative(rootDir, file))
31
+ }
32
+
33
+ function readText(rootDir: string, file: string) {
34
+ return fs.readFileSync(resolveRootPath(rootDir, file), 'utf8')
35
+ }
36
+
37
+ function writeText(rootDir: string, file: string, text: string) {
38
+ fs.writeFileSync(resolveRootPath(rootDir, file), text)
39
+ }
40
+
41
+ function readStoryTitle(text: string) {
42
+ const titleMatch = text.match(/const\s+meta\s*=\s*{[\s\S]*?title:\s*['"`]([^'"`]+)['"`]/m)
43
+ return titleMatch?.[1] ?? ''
44
+ }
45
+
46
+ function replaceStoryTitlePrefix(text: string, expectedPrefix: string) {
47
+ return text.replace(
48
+ /(const\s+meta\s*=\s*{[\s\S]*?title:\s*['"`])([^'"`]+)(['"`])/m,
49
+ (_match, before: string, currentTitle: string, after: string) => {
50
+ const titleLabel = currentTitle.includes('/') ? currentTitle.split('/').filter(Boolean).at(-1) : currentTitle
51
+ return `${before}${expectedPrefix}${titleLabel}${after}`
52
+ },
53
+ )
54
+ }
55
+
56
+ function collectFiles(rootDir: string, dir: string, suffix: string) {
57
+ const absoluteDir = resolveRootPath(rootDir, dir)
58
+ const results: string[] = []
59
+ const stack = [absoluteDir]
60
+
61
+ while (stack.length > 0) {
62
+ const current = stack.pop()
63
+ if (!current || !fs.existsSync(current)) {
64
+ continue
65
+ }
66
+
67
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
68
+ const fullPath = path.join(current, entry.name)
69
+ if (entry.isDirectory()) {
70
+ stack.push(fullPath)
71
+ continue
72
+ }
73
+ if (entry.isFile() && entry.name.endsWith(suffix)) {
74
+ results.push(toRootRelative(rootDir, fullPath))
75
+ }
76
+ }
77
+ }
78
+
79
+ return results.sort()
80
+ }
81
+
82
+ function collectSceneFolders(options: SceneConventionLintOptions) {
83
+ const sourceRootPath = resolveRootPath(options.rootDir, options.sourceRoot)
84
+ const sharedFolderName = options.sharedFolderName ?? '_shared'
85
+
86
+ if (!fs.existsSync(sourceRootPath)) {
87
+ return []
88
+ }
89
+
90
+ return fs
91
+ .readdirSync(sourceRootPath, { withFileTypes: true })
92
+ .filter((entry) => entry.isDirectory())
93
+ .filter((entry) => entry.name !== sharedFolderName)
94
+ .filter((entry) => !options.checkedFolderPrefix || entry.name.startsWith(options.checkedFolderPrefix))
95
+ .map((entry) => toRootRelative(options.rootDir, path.join(sourceRootPath, entry.name)))
96
+ .sort()
97
+ }
98
+
99
+ function lintSceneFolder(options: SceneConventionLintOptions, folder: string) {
100
+ const slug = path.posix.basename(folder)
101
+ const errors: string[] = []
102
+ const requiredFiles = [
103
+ `${slug}.tsx`,
104
+ `${slug}-Landscape.tsx`,
105
+ `${slug}-Horizontal.tsx`,
106
+ `${slug}.stories.tsx`,
107
+ `${slug}.animated.stories.tsx`,
108
+ `${slug}-Horizontal.stories.tsx`,
109
+ `${slug}-Horizontal.animated.stories.tsx`,
110
+ `${slug}.test.tsx`,
111
+ 'index.ts',
112
+ ]
113
+
114
+ for (const fileName of requiredFiles) {
115
+ const requiredPath = `${folder}/${fileName}`
116
+ if (!fs.existsSync(resolveRootPath(options.rootDir, requiredPath))) {
117
+ errors.push(`${folder}: missing ${fileName}`)
118
+ }
119
+ }
120
+
121
+ const filesToCheck = [
122
+ `${folder}/${slug}.tsx`,
123
+ `${folder}/${slug}-Landscape.tsx`,
124
+ `${folder}/${slug}-Horizontal.tsx`,
125
+ ]
126
+
127
+ for (const file of filesToCheck) {
128
+ if (!fs.existsSync(resolveRootPath(options.rootDir, file))) {
129
+ continue
130
+ }
131
+
132
+ const text = readText(options.rootDir, file)
133
+ const sourceFileMatch = text.match(/sourceFile:\s*['"`]([^'"`]+)['"`]/)
134
+ if (!sourceFileMatch) {
135
+ errors.push(`${file}: missing componentMetadata.sourceFile`)
136
+ } else if (sourceFileMatch[1] !== file) {
137
+ errors.push(`${file}: sourceFile must be "${file}", got "${sourceFileMatch[1]}"`)
138
+ }
139
+ }
140
+
141
+ const baseFile = `${folder}/${slug}.tsx`
142
+ if (fs.existsSync(resolveRootPath(options.rootDir, baseFile))) {
143
+ const text = readText(options.rootDir, baseFile)
144
+ if (!/defineSceneComponent\s*\(/.test(text)) {
145
+ errors.push(`${baseFile}: base scene must use defineSceneComponent(...)`)
146
+ }
147
+ if (!/propsSchema:\s*z\.object\s*\(/.test(text)) {
148
+ errors.push(`${baseFile}: base scene must colocate propsSchema with z.object(...)`)
149
+ }
150
+ if (!/useRemotionSceneRuntime\s*\(/.test(text)) {
151
+ errors.push(`${baseFile}: base scene must use useRemotionSceneRuntime()`)
152
+ }
153
+ if (!/SceneMotionLayer|SceneMotionStack|useSceneMotion/.test(text)) {
154
+ errors.push(`${baseFile}: scene animation must use SceneMotionLayer, SceneMotionStack, or useSceneMotion`)
155
+ }
156
+ }
157
+
158
+ const landscapeFile = `${folder}/${slug}-Landscape.tsx`
159
+ if (
160
+ fs.existsSync(resolveRootPath(options.rootDir, landscapeFile)) &&
161
+ !/aspectRatio:\s*['"]16:9['"]/.test(readText(options.rootDir, landscapeFile))
162
+ ) {
163
+ errors.push(`${landscapeFile}: Landscape variant must set aspectRatio: '16:9'`)
164
+ }
165
+
166
+ const horizontalFile = `${folder}/${slug}-Horizontal.tsx`
167
+ if (
168
+ fs.existsSync(resolveRootPath(options.rootDir, horizontalFile)) &&
169
+ !/aspectRatio:\s*['"]9:16['"]/.test(readText(options.rootDir, horizontalFile))
170
+ ) {
171
+ errors.push(`${horizontalFile}: Horizontal variant must set aspectRatio: '9:16'`)
172
+ }
173
+
174
+ return errors
175
+ }
176
+
177
+ function lintStoryFile(options: SceneConventionLintOptions, storyFile: string) {
178
+ const normalized = toRootRelative(options.rootDir, storyFile)
179
+ const text = readText(options.rootDir, normalized)
180
+ const errors: string[] = []
181
+ const storyDir = path.posix.dirname(normalized.slice(`${options.sourceRoot}/`.length))
182
+ const expectedPrefix = `${options.titleRoot}/${storyDir}/`
183
+ const title = readStoryTitle(text)
184
+ const isAnimatedStory = normalized.endsWith('.animated.stories.tsx')
185
+ const isHorizontalStory = /-Horizontal(\.animated)?\.stories\.tsx$/.test(normalized)
186
+ const isLandscapeStory = !isHorizontalStory
187
+
188
+ if (!title.startsWith(expectedPrefix)) {
189
+ if (options.fix) {
190
+ writeText(options.rootDir, normalized, replaceStoryTitlePrefix(text, expectedPrefix))
191
+ } else {
192
+ errors.push(`${normalized}: story title must start with "${expectedPrefix}"`)
193
+ }
194
+ }
195
+
196
+ if (isAnimatedStory && !/scenePreviewMode:\s*['"]animation['"]/.test(text)) {
197
+ errors.push(`${normalized}: animated stories must set scenePreviewMode: 'animation'`)
198
+ }
199
+
200
+ if (isLandscapeStory && !/scenePreviewAspectRatio:\s*['"]16:9['"]/.test(text)) {
201
+ errors.push(`${normalized}: landscape stories must set scenePreviewAspectRatio: '16:9'`)
202
+ }
203
+
204
+ if (isHorizontalStory && !/scenePreviewAspectRatio:\s*['"]9:16['"]/.test(text)) {
205
+ errors.push(`${normalized}: horizontal stories must set scenePreviewAspectRatio: '9:16'`)
206
+ }
207
+
208
+ return errors
209
+ }
210
+
211
+ function lintSharedFolder(options: SceneConventionLintOptions) {
212
+ const sharedFolderName = options.sharedFolderName ?? '_shared'
213
+ const sharedRoot = `${options.sourceRoot}/${sharedFolderName}`
214
+ const sharedRootPath = resolveRootPath(options.rootDir, sharedRoot)
215
+ const errors: string[] = []
216
+
217
+ if (!fs.existsSync(sharedRootPath)) {
218
+ return errors
219
+ }
220
+
221
+ for (const file of collectFiles(options.rootDir, sharedRoot, '.tsx')) {
222
+ const text = readText(options.rootDir, file)
223
+ if (/componentMetadata|defineComponentProjectComponentMetadata|defineScenePluginMetadata/.test(text)) {
224
+ errors.push(`${file}: shared files must not declare publishable component metadata`)
225
+ }
226
+ }
227
+
228
+ return errors
229
+ }
230
+
231
+ export function lintSceneConvention(options: SceneConventionLintOptions): SceneConventionLintResult {
232
+ const sceneFolders = collectSceneFolders(options)
233
+ const storyFiles = collectFiles(options.rootDir, options.sourceRoot, '.stories.tsx').filter(
234
+ (file) => !file.includes(`/${options.sharedFolderName ?? '_shared'}/`),
235
+ )
236
+ const errors = [
237
+ ...sceneFolders.flatMap((folder) => lintSceneFolder(options, folder)),
238
+ ...storyFiles.flatMap((file) => lintStoryFile(options, file)),
239
+ ...lintSharedFolder(options),
240
+ ]
241
+
242
+ return {
243
+ checkedSceneFolders: sceneFolders,
244
+ checkedStoryFiles: storyFiles,
245
+ errors,
246
+ }
247
+ }
@@ -0,0 +1,19 @@
1
+ export { SceneViewport } from './SceneViewport'
2
+ export { createSceneStoryVariants } from './scene-story'
3
+ export {
4
+ AbsoluteFill,
5
+ Audio,
6
+ Easing,
7
+ HtmlInCanvas,
8
+ Img,
9
+ RemotionPreviewProvider,
10
+ Sequence,
11
+ interpolate,
12
+ isHtmlInCanvasSupported,
13
+ random,
14
+ spring,
15
+ staticFile,
16
+ useCurrentFrame,
17
+ useVideoConfig,
18
+ } from './remotion-preview'
19
+ export type { RemotionPreviewValue } from './remotion-preview'
@@ -0,0 +1,53 @@
1
+ import { render } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import {
5
+ AbsoluteFill,
6
+ Easing,
7
+ Img,
8
+ RemotionPreviewProvider,
9
+ Sequence,
10
+ interpolate,
11
+ random,
12
+ spring,
13
+ staticFile,
14
+ useCurrentFrame,
15
+ useVideoConfig,
16
+ } from './remotion-preview'
17
+
18
+ describe('storybook remotion preview shim', () => {
19
+ it('exports spring, interpolate and helpers', () => {
20
+ expect(spring({ frame: 30, fps: 30 })).toBeGreaterThan(0)
21
+ expect(interpolate(0.5, [0, 1], [10, 20])).toBe(15)
22
+ expect(random('seed')).toBeGreaterThanOrEqual(0)
23
+ expect(staticFile('/foo.png')).toBe('/foo.png')
24
+ expect(Easing.linear(0.5)).toBe(0.5)
25
+ expect(AbsoluteFill).toBeTypeOf('function')
26
+ expect(Sequence).toBeTypeOf('function')
27
+ expect(Img).toBeTypeOf('function')
28
+ })
29
+
30
+ it('provides frame and video config through the provider', () => {
31
+ const Probe = () => {
32
+ const frame = useCurrentFrame()
33
+ const config = useVideoConfig()
34
+ return (
35
+ <div>
36
+ frame:{frame}
37
+ width:{config.width}
38
+ height:{config.height}
39
+ </div>
40
+ )
41
+ }
42
+
43
+ const { getByText } = render(
44
+ <RemotionPreviewProvider value={{ frame: 12, fps: 30, width: 720, height: 1280, durationInFrames: 150 }}>
45
+ <Probe />
46
+ </RemotionPreviewProvider>,
47
+ )
48
+
49
+ expect(getByText(/frame:12/)).toBeTruthy()
50
+ expect(getByText(/width:720/)).toBeTruthy()
51
+ expect(getByText(/height:1280/)).toBeTruthy()
52
+ })
53
+ })
@@ -1,7 +1,7 @@
1
1
  import type { CSSProperties, ReactNode } from 'react'
2
2
  import { createContext, useContext } from 'react'
3
3
 
4
- type RemotionPreviewValue = {
4
+ export type RemotionPreviewValue = {
5
5
  frame: number
6
6
  fps: number
7
7
  width: number
@@ -60,9 +60,9 @@ type SpringConfig = {
60
60
  delay?: number
61
61
  }
62
62
 
63
- function clamp01(value: number) {
64
- if (value < 0) return 0
65
- if (value > 1) return 1
63
+ function clamp(value: number, min: number, max: number) {
64
+ if (value < min) return min
65
+ if (value > max) return max
66
66
  return value
67
67
  }
68
68
 
@@ -84,19 +84,13 @@ export function spring({
84
84
  const eased = 1 - Math.exp(-progress * (damping / Math.max(0.001, mass)))
85
85
  const wobble = Math.sin(progress * Math.PI * (stiffness / 55)) * 0.015
86
86
 
87
- return clamp01(eased + wobble)
87
+ return clamp(eased + wobble, 0, 1)
88
88
  }
89
89
 
90
90
  type Extrapolate = 'clamp' | 'extend' | 'identity'
91
91
 
92
92
  type EasingFunction = (value: number) => number
93
93
 
94
- function clamp(value: number, min: number, max: number) {
95
- if (value < min) return min
96
- if (value > max) return max
97
- return value
98
- }
99
-
100
94
  function wrapInOut(easing: EasingFunction): EasingFunction {
101
95
  return (value) => {
102
96
  if (value <= 0.5) {
@@ -197,6 +191,22 @@ export function Img({
197
191
  return <img {...rest} src={src} alt={alt} style={style} />
198
192
  }
199
193
 
194
+ export function Audio({
195
+ src,
196
+ volume,
197
+ style,
198
+ ...rest
199
+ }: {
200
+ src: string
201
+ volume?: number | ((frame: number) => number)
202
+ style?: CSSProperties
203
+ [key: string]: unknown
204
+ }) {
205
+ void volume
206
+
207
+ return <audio {...rest} src={src} style={style} />
208
+ }
209
+
200
210
  export const HTML_IN_CANVAS_UNSUPPORTED_MESSAGE =
201
211
  'HtmlInCanvas is not available in Storybook preview, falling back to the DOM renderer.'
202
212
 
@@ -216,10 +226,7 @@ type HtmlInCanvasProps = {
216
226
  style?: CSSProperties
217
227
  }
218
228
 
219
- function HtmlInCanvasFallback({
220
- children,
221
- style,
222
- }: HtmlInCanvasProps) {
229
+ function HtmlInCanvasFallback({ children, style }: HtmlInCanvasProps) {
223
230
  return (
224
231
  <div
225
232
  style={{
@@ -264,3 +271,26 @@ export function AbsoluteFill({
264
271
  </div>
265
272
  )
266
273
  }
274
+
275
+ export function Sequence({
276
+ children,
277
+ }: {
278
+ from?: number
279
+ durationInFrames?: number
280
+ children?: ReactNode
281
+ }) {
282
+ return <>{children}</>
283
+ }
284
+
285
+ export function random(seed?: string | number) {
286
+ const value = String(seed ?? 0)
287
+ let hash = 0
288
+ for (let index = 0; index < value.length; index += 1) {
289
+ hash = (hash * 31 + value.charCodeAt(index)) >>> 0
290
+ }
291
+ return (hash % 10000) / 10000
292
+ }
293
+
294
+ export function staticFile(path: string) {
295
+ return path
296
+ }