@basementstudio/shader-lab 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/.biome/plugins/README.md +21 -0
  2. package/.biome/plugins/no-anchor-element.grit +12 -0
  3. package/.biome/plugins/no-relative-parent-imports.grit +10 -0
  4. package/.biome/plugins/no-unnecessary-forwardref.grit +9 -0
  5. package/.changeset/README.md +17 -0
  6. package/.changeset/config.json +11 -0
  7. package/.editorconfig +40 -0
  8. package/.env.example +81 -0
  9. package/.gitattributes +19 -0
  10. package/.github/workflows/canary.yml +80 -0
  11. package/.github/workflows/ci.yml +37 -0
  12. package/.github/workflows/release.yml +56 -0
  13. package/.tldrignore +84 -0
  14. package/.vscode/extensions.json +20 -0
  15. package/.vscode/settings.json +105 -0
  16. package/README.md +119 -0
  17. package/biome.json +249 -0
  18. package/bun.lock +1224 -0
  19. package/next.config.ts +131 -0
  20. package/package.json +73 -0
  21. package/packages/shader-lab-react/CHANGELOG.md +9 -0
  22. package/packages/shader-lab-react/README.md +119 -0
  23. package/packages/shader-lab-react/assets/patterns/bars/1.svg +3 -0
  24. package/packages/shader-lab-react/assets/patterns/bars/2.svg +3 -0
  25. package/packages/shader-lab-react/assets/patterns/bars/3.svg +3 -0
  26. package/packages/shader-lab-react/assets/patterns/bars/4.svg +3 -0
  27. package/packages/shader-lab-react/assets/patterns/bars/5.svg +3 -0
  28. package/packages/shader-lab-react/assets/patterns/bars/6.svg +3 -0
  29. package/packages/shader-lab-react/assets/patterns/candles/1.svg +3 -0
  30. package/packages/shader-lab-react/assets/patterns/candles/2.svg +3 -0
  31. package/packages/shader-lab-react/assets/patterns/candles/3.svg +3 -0
  32. package/packages/shader-lab-react/assets/patterns/candles/4.svg +3 -0
  33. package/packages/shader-lab-react/assets/patterns/shapes/1.svg +3 -0
  34. package/packages/shader-lab-react/assets/patterns/shapes/2.svg +3 -0
  35. package/packages/shader-lab-react/assets/patterns/shapes/3.svg +3 -0
  36. package/packages/shader-lab-react/assets/patterns/shapes/4.svg +4 -0
  37. package/packages/shader-lab-react/assets/patterns/shapes/5.svg +3 -0
  38. package/packages/shader-lab-react/assets/patterns/shapes/6.svg +4 -0
  39. package/packages/shader-lab-react/assets/textures/blue-noise.png +0 -0
  40. package/packages/shader-lab-react/package.json +36 -0
  41. package/packages/shader-lab-react/scripts/fix-esm-specifiers.mjs +57 -0
  42. package/packages/shader-lab-react/scripts/prepare-dist.mjs +4 -0
  43. package/packages/shader-lab-react/src/ambient/three-tsl.d.ts +146 -0
  44. package/packages/shader-lab-react/src/ambient/three-webgpu.d.ts +51 -0
  45. package/packages/shader-lab-react/src/easings.ts +4 -0
  46. package/packages/shader-lab-react/src/index.ts +35 -0
  47. package/packages/shader-lab-react/src/lib/editor/custom-shader/shared.ts +2 -0
  48. package/packages/shader-lab-react/src/renderer/ascii-atlas.ts +83 -0
  49. package/packages/shader-lab-react/src/renderer/ascii-pass.ts +416 -0
  50. package/packages/shader-lab-react/src/renderer/asset-url.ts +3 -0
  51. package/packages/shader-lab-react/src/renderer/blend-modes.ts +229 -0
  52. package/packages/shader-lab-react/src/renderer/contracts.ts +54 -0
  53. package/packages/shader-lab-react/src/renderer/create-webgpu-renderer.ts +48 -0
  54. package/packages/shader-lab-react/src/renderer/crt-pass.ts +1040 -0
  55. package/packages/shader-lab-react/src/renderer/custom-shader-pass.ts +108 -0
  56. package/packages/shader-lab-react/src/renderer/custom-shader-runtime.ts +309 -0
  57. package/packages/shader-lab-react/src/renderer/dither-textures.ts +99 -0
  58. package/packages/shader-lab-react/src/renderer/dithering-pass.ts +322 -0
  59. package/packages/shader-lab-react/src/renderer/gradient-pass.ts +521 -0
  60. package/packages/shader-lab-react/src/renderer/halftone-pass.ts +932 -0
  61. package/packages/shader-lab-react/src/renderer/ink-pass.ts +802 -0
  62. package/packages/shader-lab-react/src/renderer/live-pass.ts +194 -0
  63. package/packages/shader-lab-react/src/renderer/media-pass.ts +187 -0
  64. package/packages/shader-lab-react/src/renderer/media-texture.ts +66 -0
  65. package/packages/shader-lab-react/src/renderer/particle-grid-pass.ts +389 -0
  66. package/packages/shader-lab-react/src/renderer/pass-node.ts +209 -0
  67. package/packages/shader-lab-react/src/renderer/pattern-atlas.ts +133 -0
  68. package/packages/shader-lab-react/src/renderer/pattern-pass.ts +552 -0
  69. package/packages/shader-lab-react/src/renderer/pipeline-manager.ts +369 -0
  70. package/packages/shader-lab-react/src/renderer/pixel-sorting-pass.ts +277 -0
  71. package/packages/shader-lab-react/src/renderer/shaders/tsl/color/tonemapping.ts +87 -0
  72. package/packages/shader-lab-react/src/renderer/shaders/tsl/cosine-palette.ts +9 -0
  73. package/packages/shader-lab-react/src/renderer/shaders/tsl/noise/common.ts +31 -0
  74. package/packages/shader-lab-react/src/renderer/shaders/tsl/noise/curl-noise-3d.ts +36 -0
  75. package/packages/shader-lab-react/src/renderer/shaders/tsl/noise/curl-noise-4d.ts +36 -0
  76. package/packages/shader-lab-react/src/renderer/shaders/tsl/noise/fbm.ts +13 -0
  77. package/packages/shader-lab-react/src/renderer/shaders/tsl/noise/perlin-noise-3d.ts +96 -0
  78. package/packages/shader-lab-react/src/renderer/shaders/tsl/noise/ridge-noise.ts +24 -0
  79. package/packages/shader-lab-react/src/renderer/shaders/tsl/noise/simplex-noise-3d.ts +79 -0
  80. package/packages/shader-lab-react/src/renderer/shaders/tsl/noise/simplex-noise-4d.ts +89 -0
  81. package/packages/shader-lab-react/src/renderer/shaders/tsl/noise/turbulence.ts +56 -0
  82. package/packages/shader-lab-react/src/renderer/shaders/tsl/noise/value-noise-3d.ts +32 -0
  83. package/packages/shader-lab-react/src/renderer/shaders/tsl/noise/voronoi-noise-3d.ts +60 -0
  84. package/packages/shader-lab-react/src/renderer/shaders/tsl/patterns/bloom-edge-pattern.ts +15 -0
  85. package/packages/shader-lab-react/src/renderer/shaders/tsl/patterns/bloom.ts +11 -0
  86. package/packages/shader-lab-react/src/renderer/shaders/tsl/patterns/canvas-weave-pattern.ts +24 -0
  87. package/packages/shader-lab-react/src/renderer/shaders/tsl/patterns/grain-texture-pattern.ts +9 -0
  88. package/packages/shader-lab-react/src/renderer/shaders/tsl/patterns/repeating-pattern.ts +11 -0
  89. package/packages/shader-lab-react/src/renderer/shaders/tsl/utils/atan2.ts +9 -0
  90. package/packages/shader-lab-react/src/renderer/shaders/tsl/utils/complex-conj.ts +9 -0
  91. package/packages/shader-lab-react/src/renderer/shaders/tsl/utils/complex-cos.ts +10 -0
  92. package/packages/shader-lab-react/src/renderer/shaders/tsl/utils/complex-div.ts +11 -0
  93. package/packages/shader-lab-react/src/renderer/shaders/tsl/utils/complex-log.ts +7 -0
  94. package/packages/shader-lab-react/src/renderer/shaders/tsl/utils/complex-mobius.ts +12 -0
  95. package/packages/shader-lab-react/src/renderer/shaders/tsl/utils/complex-mul.ts +9 -0
  96. package/packages/shader-lab-react/src/renderer/shaders/tsl/utils/complex-pow.ts +16 -0
  97. package/packages/shader-lab-react/src/renderer/shaders/tsl/utils/complex-sin.ts +10 -0
  98. package/packages/shader-lab-react/src/renderer/shaders/tsl/utils/complex-sqrt.ts +18 -0
  99. package/packages/shader-lab-react/src/renderer/shaders/tsl/utils/complex-tan.ts +12 -0
  100. package/packages/shader-lab-react/src/renderer/shaders/tsl/utils/complex-to-polar.ts +10 -0
  101. package/packages/shader-lab-react/src/renderer/shaders/tsl/utils/hyperbolic.ts +20 -0
  102. package/packages/shader-lab-react/src/renderer/shaders/tsl/utils/index.ts +48 -0
  103. package/packages/shader-lab-react/src/renderer/shaders/tsl/utils/rotate.ts +15 -0
  104. package/packages/shader-lab-react/src/renderer/shaders/tsl/utils/screen-aspect-uv.ts +15 -0
  105. package/packages/shader-lab-react/src/renderer/shaders/tsl/utils/sd-box-2d.ts +6 -0
  106. package/packages/shader-lab-react/src/renderer/shaders/tsl/utils/sd-diamond.ts +6 -0
  107. package/packages/shader-lab-react/src/renderer/shaders/tsl/utils/sd-rhombus.ts +27 -0
  108. package/packages/shader-lab-react/src/renderer/shaders/tsl/utils/sd-sphere.ts +6 -0
  109. package/packages/shader-lab-react/src/renderer/shaders/tsl/utils/smax.ts +7 -0
  110. package/packages/shader-lab-react/src/renderer/shaders/tsl/utils/smin.ts +7 -0
  111. package/packages/shader-lab-react/src/renderer/text-pass.ts +176 -0
  112. package/packages/shader-lab-react/src/runtime-clock.ts +42 -0
  113. package/packages/shader-lab-react/src/runtime-frame.ts +29 -0
  114. package/packages/shader-lab-react/src/shader-lab-composition.tsx +163 -0
  115. package/packages/shader-lab-react/src/timeline.ts +283 -0
  116. package/packages/shader-lab-react/src/types/editor.ts +5 -0
  117. package/packages/shader-lab-react/src/types.ts +141 -0
  118. package/packages/shader-lab-react/tsconfig.build.json +8 -0
  119. package/packages/shader-lab-react/tsconfig.json +21 -0
  120. package/postcss.config.mjs +5 -0
  121. package/public/assets/fonts/msdf/geist-mono/GeistMono-Regular-msdf-atlas.png +0 -0
  122. package/public/assets/fonts/msdf/geist-mono/GeistMono-Regular-msdf.json +1412 -0
  123. package/public/assets/patterns/bars/1.svg +3 -0
  124. package/public/assets/patterns/bars/2.svg +3 -0
  125. package/public/assets/patterns/bars/3.svg +3 -0
  126. package/public/assets/patterns/bars/4.svg +3 -0
  127. package/public/assets/patterns/bars/5.svg +3 -0
  128. package/public/assets/patterns/bars/6.svg +3 -0
  129. package/public/assets/patterns/candles/1.svg +3 -0
  130. package/public/assets/patterns/candles/2.svg +3 -0
  131. package/public/assets/patterns/candles/3.svg +3 -0
  132. package/public/assets/patterns/candles/4.svg +3 -0
  133. package/public/assets/patterns/shapes/1.svg +3 -0
  134. package/public/assets/patterns/shapes/2.svg +3 -0
  135. package/public/assets/patterns/shapes/3.svg +3 -0
  136. package/public/assets/patterns/shapes/4.svg +4 -0
  137. package/public/assets/patterns/shapes/5.svg +3 -0
  138. package/public/assets/patterns/shapes/6.svg +4 -0
  139. package/public/fonts/geist/Geist-Mono.woff2 +0 -0
  140. package/public/textures/blue-noise.png +0 -0
  141. package/public/textures/crt-mask.png +0 -0
  142. package/src/app/design/page.tsx +398 -0
  143. package/src/app/favicon.ico +0 -0
  144. package/src/app/globals.css +280 -0
  145. package/src/app/layout.tsx +89 -0
  146. package/src/app/page.tsx +20 -0
  147. package/src/app/robots.ts +13 -0
  148. package/src/app/sitemap.ts +13 -0
  149. package/src/components/editor/editor-canvas-viewport.tsx +116 -0
  150. package/src/components/editor/editor-export-dialog.tsx +1177 -0
  151. package/src/components/editor/editor-timeline-overlay.tsx +983 -0
  152. package/src/components/editor/editor-topbar.tsx +287 -0
  153. package/src/components/editor/layer-sidebar.tsx +738 -0
  154. package/src/components/editor/properties-sidebar-content.tsx +574 -0
  155. package/src/components/editor/properties-sidebar-fields.tsx +389 -0
  156. package/src/components/editor/properties-sidebar-utils.ts +178 -0
  157. package/src/components/editor/properties-sidebar.tsx +421 -0
  158. package/src/components/ui/button/index.tsx +57 -0
  159. package/src/components/ui/color-picker/index.tsx +358 -0
  160. package/src/components/ui/glass-panel/index.tsx +45 -0
  161. package/src/components/ui/icon-button/index.tsx +46 -0
  162. package/src/components/ui/select/index.tsx +136 -0
  163. package/src/components/ui/slider/index.tsx +192 -0
  164. package/src/components/ui/toggle/index.tsx +34 -0
  165. package/src/components/ui/typography/index.tsx +61 -0
  166. package/src/components/ui/xy-pad/index.tsx +160 -0
  167. package/src/features/editor/components/editor-export-dialog.module.css +271 -0
  168. package/src/hooks/use-editor-renderer.ts +182 -0
  169. package/src/lib/app.ts +6 -0
  170. package/src/lib/cn.ts +7 -0
  171. package/src/lib/easings.ts +240 -0
  172. package/src/lib/editor/config/layer-registry.ts +2434 -0
  173. package/src/lib/editor/custom-shader/shared.ts +28 -0
  174. package/src/lib/editor/export.ts +420 -0
  175. package/src/lib/editor/history.ts +71 -0
  176. package/src/lib/editor/layers.ts +76 -0
  177. package/src/lib/editor/parameter-schema.ts +75 -0
  178. package/src/lib/editor/project-file.ts +145 -0
  179. package/src/lib/editor/shader-export-snippet.ts +37 -0
  180. package/src/lib/editor/shader-export.ts +315 -0
  181. package/src/lib/editor/timeline/evaluate.ts +252 -0
  182. package/src/lib/editor/view-transform.ts +58 -0
  183. package/src/lib/fonts.ts +28 -0
  184. package/src/renderer/ascii-atlas.ts +83 -0
  185. package/src/renderer/ascii-pass.ts +416 -0
  186. package/src/renderer/blend-modes.ts +229 -0
  187. package/src/renderer/contracts.ts +161 -0
  188. package/src/renderer/create-webgpu-renderer.ts +48 -0
  189. package/src/renderer/crt-pass.ts +1040 -0
  190. package/src/renderer/custom-shader-pass.ts +117 -0
  191. package/src/renderer/custom-shader-runtime.ts +309 -0
  192. package/src/renderer/dither-textures.ts +99 -0
  193. package/src/renderer/dithering-pass.ts +322 -0
  194. package/src/renderer/gradient-pass.ts +520 -0
  195. package/src/renderer/halftone-pass.ts +932 -0
  196. package/src/renderer/ink-pass.ts +683 -0
  197. package/src/renderer/live-pass.ts +194 -0
  198. package/src/renderer/media-pass.ts +187 -0
  199. package/src/renderer/media-texture.ts +66 -0
  200. package/src/renderer/particle-grid-pass.ts +389 -0
  201. package/src/renderer/pass-node-factory.ts +33 -0
  202. package/src/renderer/pass-node.ts +209 -0
  203. package/src/renderer/pattern-atlas.ts +97 -0
  204. package/src/renderer/pattern-pass.ts +552 -0
  205. package/src/renderer/pipeline-manager.ts +343 -0
  206. package/src/renderer/pixel-sorting-pass.ts +277 -0
  207. package/src/renderer/project-clock.ts +57 -0
  208. package/src/renderer/shaders/tsl/color/tonemapping.ts +86 -0
  209. package/src/renderer/shaders/tsl/cosine-palette.ts +8 -0
  210. package/src/renderer/shaders/tsl/noise/common.ts +30 -0
  211. package/src/renderer/shaders/tsl/noise/curl-noise-3d.ts +35 -0
  212. package/src/renderer/shaders/tsl/noise/curl-noise-4d.ts +35 -0
  213. package/src/renderer/shaders/tsl/noise/fbm.ts +12 -0
  214. package/src/renderer/shaders/tsl/noise/perlin-noise-3d.ts +97 -0
  215. package/src/renderer/shaders/tsl/noise/ridge-noise.ts +23 -0
  216. package/src/renderer/shaders/tsl/noise/simplex-noise-3d.ts +78 -0
  217. package/src/renderer/shaders/tsl/noise/simplex-noise-4d.ts +88 -0
  218. package/src/renderer/shaders/tsl/noise/turbulence.ts +55 -0
  219. package/src/renderer/shaders/tsl/noise/value-noise-3d.ts +31 -0
  220. package/src/renderer/shaders/tsl/noise/voronoi-noise-3d.ts +59 -0
  221. package/src/renderer/shaders/tsl/patterns/bloom-edge-pattern.ts +14 -0
  222. package/src/renderer/shaders/tsl/patterns/bloom.ts +10 -0
  223. package/src/renderer/shaders/tsl/patterns/canvas-weave-pattern.ts +23 -0
  224. package/src/renderer/shaders/tsl/patterns/grain-texture-pattern.ts +8 -0
  225. package/src/renderer/shaders/tsl/patterns/repeating-pattern.ts +10 -0
  226. package/src/renderer/shaders/tsl/utils/atan2.ts +8 -0
  227. package/src/renderer/shaders/tsl/utils/complex-conj.ts +8 -0
  228. package/src/renderer/shaders/tsl/utils/complex-cos.ts +9 -0
  229. package/src/renderer/shaders/tsl/utils/complex-div.ts +10 -0
  230. package/src/renderer/shaders/tsl/utils/complex-log.ts +6 -0
  231. package/src/renderer/shaders/tsl/utils/complex-mobius.ts +11 -0
  232. package/src/renderer/shaders/tsl/utils/complex-mul.ts +8 -0
  233. package/src/renderer/shaders/tsl/utils/complex-pow.ts +15 -0
  234. package/src/renderer/shaders/tsl/utils/complex-sin.ts +9 -0
  235. package/src/renderer/shaders/tsl/utils/complex-sqrt.ts +17 -0
  236. package/src/renderer/shaders/tsl/utils/complex-tan.ts +11 -0
  237. package/src/renderer/shaders/tsl/utils/complex-to-polar.ts +9 -0
  238. package/src/renderer/shaders/tsl/utils/hyperbolic.ts +19 -0
  239. package/src/renderer/shaders/tsl/utils/index.ts +47 -0
  240. package/src/renderer/shaders/tsl/utils/rotate.ts +14 -0
  241. package/src/renderer/shaders/tsl/utils/screen-aspect-uv.ts +14 -0
  242. package/src/renderer/shaders/tsl/utils/sd-box-2d.ts +5 -0
  243. package/src/renderer/shaders/tsl/utils/sd-diamond.ts +5 -0
  244. package/src/renderer/shaders/tsl/utils/sd-rhombus.ts +26 -0
  245. package/src/renderer/shaders/tsl/utils/sd-sphere.ts +5 -0
  246. package/src/renderer/shaders/tsl/utils/smax.ts +7 -0
  247. package/src/renderer/shaders/tsl/utils/smin.ts +6 -0
  248. package/src/renderer/text-pass.ts +176 -0
  249. package/src/store/asset-store.ts +193 -0
  250. package/src/store/editor-store.ts +223 -0
  251. package/src/store/history-store.ts +172 -0
  252. package/src/store/index.ts +31 -0
  253. package/src/store/layer-store.ts +675 -0
  254. package/src/store/timeline-store.ts +572 -0
  255. package/src/types/assets.d.ts +6 -0
  256. package/src/types/css.d.ts +21 -0
  257. package/src/types/editor.ts +357 -0
  258. package/src/types/react.d.ts +15 -0
  259. package/src/types/three-tsl.d.ts +146 -0
  260. package/src/types/three-webgpu.d.ts +51 -0
  261. package/tsconfig.json +49 -0
@@ -0,0 +1,983 @@
1
+ "use client"
2
+
3
+ import { Select as BaseSelect } from "@base-ui/react/select"
4
+ import {
5
+ BezierCurveIcon,
6
+ CaretDownIcon,
7
+ CaretUpIcon,
8
+ PauseIcon,
9
+ PlayIcon,
10
+ StopIcon,
11
+ } from "@phosphor-icons/react"
12
+ import { motion, useReducedMotion } from "motion/react"
13
+ import {
14
+ type CSSProperties,
15
+ type PointerEvent as ReactPointerEvent,
16
+ useEffect,
17
+ useEffectEvent,
18
+ useMemo,
19
+ useRef,
20
+ useState,
21
+ } from "react"
22
+ import { getLayerDefinition } from "@/lib/editor/config/layer-registry"
23
+ import type {
24
+ AnimatedPropertyBinding,
25
+ EditorLayer,
26
+ ParameterDefinition,
27
+ TimelineInterpolation,
28
+ TimelineTrack,
29
+ } from "@/types/editor"
30
+ import { TIMELINE_INTERPOLATIONS } from "@/types/editor"
31
+ import { cn } from "@/lib/cn"
32
+ import { GlassPanel } from "@/components/ui/glass-panel"
33
+ import { IconButton } from "@/components/ui/icon-button"
34
+ import { Typography } from "@/components/ui/typography"
35
+ import { useEditorStore, useLayerStore, useTimelineStore } from "@/store"
36
+ import {
37
+ createLayerPropertyBinding,
38
+ createParamBinding,
39
+ } from "@/store/timeline-store"
40
+
41
+ type TimelinePropertyItem = {
42
+ binding: AnimatedPropertyBinding
43
+ color: string
44
+ id: string
45
+ kind: "layer" | "param"
46
+ label: string
47
+ track: TimelineTrack | null
48
+ }
49
+
50
+ type DragState =
51
+ | {
52
+ type: "keyframe"
53
+ keyframeId: string
54
+ trackId: string
55
+ }
56
+ | {
57
+ type: "playhead"
58
+ }
59
+
60
+ const GENERAL_TIMELINE_PROPERTIES = [
61
+ { color: "#8DB1FF", property: "opacity" },
62
+ { color: "#A4E0A0", property: "hue" },
63
+ { color: "#F7B365", property: "saturation" },
64
+ ] as const
65
+
66
+ const COLLAPSED_SHELL_HEIGHT = 52
67
+ const COLLAPSED_SHELL_WIDTH = 580
68
+ const EXPANDED_SHELL_HEIGHT = 380
69
+ const EXPANDED_SHELL_WIDTH = 820
70
+ const INTERPOLATION_OPTIONS = TIMELINE_INTERPOLATIONS.map((value) => ({
71
+ label: value[0]?.toUpperCase() + value.slice(1),
72
+ value,
73
+ }))
74
+
75
+ function clamp(value: number, min: number, max: number): number {
76
+ return Math.min(max, Math.max(min, value))
77
+ }
78
+
79
+ function formatSeconds(value: number): string {
80
+ const safeValue = Number.isFinite(value) ? value : 0
81
+ return `${safeValue.toFixed(2)}s`
82
+ }
83
+
84
+ function hexToRgbChannels(value: string): string {
85
+ const normalized = value.replace("#", "")
86
+
87
+ if (normalized.length !== 6) {
88
+ return "122 162 255"
89
+ }
90
+
91
+ const red = Number.parseInt(normalized.slice(0, 2), 16)
92
+ const green = Number.parseInt(normalized.slice(2, 4), 16)
93
+ const blue = Number.parseInt(normalized.slice(4, 6), 16)
94
+
95
+ return `${red} ${green} ${blue}`
96
+ }
97
+
98
+ function isEditableTarget(target: EventTarget | null): boolean {
99
+ if (!(target instanceof HTMLElement)) {
100
+ return false
101
+ }
102
+
103
+ if (target.isContentEditable) {
104
+ return true
105
+ }
106
+
107
+ return ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)
108
+ }
109
+
110
+ function getPropertyId(binding: AnimatedPropertyBinding): string {
111
+ if (binding.kind === "layer") {
112
+ return `layer:${binding.property}`
113
+ }
114
+
115
+ return `param:${binding.key}`
116
+ }
117
+
118
+ function getVisibleParams(layer: EditorLayer): ParameterDefinition[] {
119
+ const definition = getLayerDefinition(layer.type)
120
+
121
+ return definition.params.filter((entry) => {
122
+ if (!entry.visibleWhen) {
123
+ return true
124
+ }
125
+
126
+ const controllingValue =
127
+ layer.params[entry.visibleWhen.key] ??
128
+ definition.params.find((param) => param.key === entry.visibleWhen?.key)
129
+ ?.defaultValue
130
+
131
+ if ("equals" in entry.visibleWhen) {
132
+ return controllingValue === entry.visibleWhen.equals
133
+ }
134
+
135
+ return (
136
+ typeof controllingValue === "number" &&
137
+ controllingValue >= entry.visibleWhen.gte
138
+ )
139
+ })
140
+ }
141
+
142
+ function buildTimelineProperties(
143
+ layer: EditorLayer | null,
144
+ tracks: TimelineTrack[]
145
+ ): TimelinePropertyItem[] {
146
+ if (!layer) {
147
+ return []
148
+ }
149
+
150
+ const properties: TimelinePropertyItem[] = GENERAL_TIMELINE_PROPERTIES.map(
151
+ (entry) => {
152
+ const binding = createLayerPropertyBinding(entry.property)
153
+ const id = getPropertyId(binding)
154
+
155
+ return {
156
+ binding,
157
+ color: entry.color,
158
+ id,
159
+ kind: "layer",
160
+ label: binding.label,
161
+ track:
162
+ tracks.find(
163
+ (track) =>
164
+ track.layerId === layer.id && getPropertyId(track.binding) === id
165
+ ) ?? null,
166
+ }
167
+ }
168
+ )
169
+
170
+ for (const definition of getVisibleParams(layer)) {
171
+ const binding = createParamBinding(layer, definition.key)
172
+
173
+ if (!binding) {
174
+ continue
175
+ }
176
+
177
+ const id = getPropertyId(binding)
178
+ properties.push({
179
+ binding,
180
+ color: definition.type === "color" ? "#FF8CAB" : "#B697FF",
181
+ id,
182
+ kind: "param",
183
+ label: definition.label,
184
+ track:
185
+ tracks.find(
186
+ (track) =>
187
+ track.layerId === layer.id && getPropertyId(track.binding) === id
188
+ ) ?? null,
189
+ })
190
+ }
191
+
192
+ return properties
193
+ }
194
+
195
+ function getMajorTickStep(duration: number): number {
196
+ if (duration <= 6) {
197
+ return 1
198
+ }
199
+
200
+ if (duration <= 12) {
201
+ return 2
202
+ }
203
+
204
+ if (duration <= 30) {
205
+ return 5
206
+ }
207
+
208
+ if (duration <= 60) {
209
+ return 10
210
+ }
211
+
212
+ return 20
213
+ }
214
+
215
+ function createTickPositions(duration: number) {
216
+ const safeDuration = Math.max(duration, 0.25)
217
+ const majorStep = getMajorTickStep(safeDuration)
218
+ const minorStep = majorStep / 4
219
+ const majorTicks: number[] = []
220
+ const minorTicks: number[] = []
221
+
222
+ for (
223
+ let current = 0;
224
+ current <= safeDuration + Number.EPSILON;
225
+ current += majorStep
226
+ ) {
227
+ majorTicks.push(Number(current.toFixed(3)))
228
+ }
229
+
230
+ if (majorTicks[majorTicks.length - 1] !== safeDuration) {
231
+ majorTicks.push(safeDuration)
232
+ }
233
+
234
+ for (
235
+ let current = 0;
236
+ current <= safeDuration + Number.EPSILON;
237
+ current += minorStep
238
+ ) {
239
+ const normalized = Number(current.toFixed(3))
240
+ if (!majorTicks.some((tick) => Math.abs(tick - normalized) < 0.001)) {
241
+ minorTicks.push(normalized)
242
+ }
243
+ }
244
+
245
+ return { majorTicks, minorTicks }
246
+ }
247
+
248
+ function TimelineTransport({
249
+ currentTime,
250
+ duration,
251
+ expanded,
252
+ isPlaying,
253
+ loop,
254
+ onDurationChange,
255
+ onStop,
256
+ onToggleExpanded,
257
+ onToggleLoop,
258
+ onTogglePlaying,
259
+ }: {
260
+ currentTime: number
261
+ duration: number
262
+ expanded: boolean
263
+ isPlaying: boolean
264
+ loop: boolean
265
+ onDurationChange: (value: number) => void
266
+ onStop: () => void
267
+ onToggleExpanded: () => void
268
+ onToggleLoop: () => void
269
+ onTogglePlaying: () => void
270
+ }) {
271
+ return (
272
+ <div
273
+ className={cn(
274
+ "flex w-full items-center gap-2",
275
+ expanded ? "min-h-[31px]" : "min-h-7"
276
+ )}
277
+ >
278
+ <div className="inline-flex items-center gap-1">
279
+ <IconButton
280
+ aria-label={isPlaying ? "Pause playback" : "Play timeline"}
281
+ className="h-7 w-7"
282
+ onClick={onTogglePlaying}
283
+ variant="default"
284
+ >
285
+ {isPlaying ? (
286
+ <PauseIcon size={14} weight="fill" />
287
+ ) : (
288
+ <PlayIcon size={14} weight="fill" />
289
+ )}
290
+ </IconButton>
291
+ <IconButton
292
+ aria-label="Stop playback"
293
+ className="h-7 w-7"
294
+ onClick={onStop}
295
+ variant="default"
296
+ >
297
+ <StopIcon size={14} weight="fill" />
298
+ </IconButton>
299
+ </div>
300
+
301
+ <span
302
+ aria-hidden="true"
303
+ className="block h-4 w-px shrink-0 rounded-full bg-[var(--ds-border-divider)]"
304
+ />
305
+
306
+ <div className="inline-flex items-center gap-1">
307
+ <IconButton
308
+ aria-label={loop ? "Disable loop" : "Enable loop"}
309
+ className={cn(
310
+ "h-7 w-auto gap-1.5 px-[10px]",
311
+ loop && "bg-white/12 text-[var(--ds-color-text-primary)]"
312
+ )}
313
+ onClick={onToggleLoop}
314
+ variant={loop ? "active" : "default"}
315
+ >
316
+ <Typography as="span" tone="secondary" variant="monoSm">
317
+ Loop
318
+ </Typography>
319
+ </IconButton>
320
+ </div>
321
+
322
+ <span
323
+ aria-hidden="true"
324
+ className="block h-4 w-px shrink-0 rounded-full bg-[var(--ds-border-divider)]"
325
+ />
326
+
327
+ <div className="inline-flex items-center gap-2">
328
+ <Typography as="span" tone="secondary" variant="monoSm">
329
+ Duration
330
+ </Typography>
331
+ <input
332
+ aria-label="Timeline duration in seconds"
333
+ className="min-h-7 w-[72px] appearance-none rounded-[var(--ds-radius-icon)] border border-[var(--ds-border-divider)] bg-[var(--ds-color-surface-control)] px-[10px] text-center font-[var(--ds-font-mono)] text-[12px] leading-4 text-[var(--ds-color-text-primary)] outline-none transition-[background-color,border-color] duration-160 ease-[var(--ease-out-cubic)] focus:border-[var(--ds-border-hover)]"
334
+ max={120}
335
+ min={0.25}
336
+ onChange={(event) => {
337
+ const nextValue = event.currentTarget.valueAsNumber
338
+
339
+ if (Number.isFinite(nextValue)) {
340
+ onDurationChange(nextValue)
341
+ }
342
+ }}
343
+ step={0.25}
344
+ type="number"
345
+ value={duration.toFixed(2)}
346
+ />
347
+ <Typography
348
+ as="span"
349
+ className="whitespace-nowrap"
350
+ tone="secondary"
351
+ variant="monoSm"
352
+ >
353
+ sec
354
+ </Typography>
355
+ </div>
356
+
357
+ <div className="inline-flex min-w-0 flex-1 items-center justify-end gap-1">
358
+ <Typography
359
+ as="span"
360
+ className="min-w-[104px] whitespace-nowrap text-right"
361
+ tone="secondary"
362
+ variant="monoMd"
363
+ >
364
+ {formatSeconds(currentTime)} / {formatSeconds(duration)}
365
+ </Typography>
366
+ <IconButton
367
+ aria-label={
368
+ expanded ? "Collapse timeline panel" : "Expand timeline panel"
369
+ }
370
+ className="h-7 w-7"
371
+ onClick={onToggleExpanded}
372
+ variant="default"
373
+ >
374
+ {expanded ? (
375
+ <CaretDownIcon size={14} weight="bold" />
376
+ ) : (
377
+ <CaretUpIcon size={14} weight="bold" />
378
+ )}
379
+ </IconButton>
380
+ </div>
381
+ </div>
382
+ )
383
+ }
384
+
385
+ export function EditorTimelineOverlay() {
386
+ const reduceMotion = useReducedMotion() ?? false
387
+ const immersiveCanvas = useEditorStore((state) => state.immersiveCanvas)
388
+ const timelinePanelOpen = useEditorStore((state) => state.timelinePanelOpen)
389
+ const closeTimelinePanel = useEditorStore((state) => state.closeTimelinePanel)
390
+ const toggleTimelinePanel = useEditorStore(
391
+ (state) => state.toggleTimelinePanel
392
+ )
393
+ const selectedLayerId = useLayerStore((state) => state.selectedLayerId)
394
+ const selectedLayer = useLayerStore((state) =>
395
+ selectedLayerId
396
+ ? (state.layers.find((layer) => layer.id === selectedLayerId) ?? null)
397
+ : null
398
+ )
399
+
400
+ const currentTime = useTimelineStore((state) => state.currentTime)
401
+ const duration = useTimelineStore((state) => state.duration)
402
+ const isPlaying = useTimelineStore((state) => state.isPlaying)
403
+ const loop = useTimelineStore((state) => state.loop)
404
+ const selectedTrackId = useTimelineStore((state) => state.selectedTrackId)
405
+ const selectedKeyframeId = useTimelineStore(
406
+ (state) => state.selectedKeyframeId
407
+ )
408
+ const tracks = useTimelineStore((state) => state.tracks)
409
+ const setCurrentTime = useTimelineStore((state) => state.setCurrentTime)
410
+ const setDuration = useTimelineStore((state) => state.setDuration)
411
+ const setLoop = useTimelineStore((state) => state.setLoop)
412
+ const setSelected = useTimelineStore((state) => state.setSelected)
413
+ const setTrackInterpolation = useTimelineStore(
414
+ (state) => state.setTrackInterpolation
415
+ )
416
+ const setKeyframeTime = useTimelineStore((state) => state.setKeyframeTime)
417
+ const removeKeyframe = useTimelineStore((state) => state.removeKeyframe)
418
+ const stop = useTimelineStore((state) => state.stop)
419
+ const togglePlaying = useTimelineStore((state) => state.togglePlaying)
420
+
421
+ const layerTracks = useMemo(
422
+ () =>
423
+ selectedLayer
424
+ ? tracks.filter((track) => track.layerId === selectedLayer.id)
425
+ : [],
426
+ [selectedLayer, tracks]
427
+ )
428
+ const properties = useMemo(
429
+ () => buildTimelineProperties(selectedLayer, tracks),
430
+ [selectedLayer, tracks]
431
+ )
432
+ const animatedProperties = useMemo(
433
+ () => properties.filter((entry) => entry.track),
434
+ [properties]
435
+ )
436
+ const [focusedPropertyId, setFocusedPropertyId] = useState<string | null>(
437
+ null
438
+ )
439
+ const scrubSurfaceRef = useRef<HTMLDivElement | null>(null)
440
+ const [dragState, setDragState] = useState<DragState | null>(null)
441
+ const [viewportSize, setViewportSize] = useState({ height: 900, width: 1440 })
442
+ const tickPositions = useMemo(() => createTickPositions(duration), [duration])
443
+
444
+ useEffect(() => {
445
+ if (!(timelinePanelOpen && selectedLayer)) {
446
+ return
447
+ }
448
+
449
+ const selectedTrack =
450
+ layerTracks.find((track) => track.id === selectedTrackId) ?? null
451
+
452
+ if (selectedTrack) {
453
+ const nextPropertyId = getPropertyId(selectedTrack.binding)
454
+ if (focusedPropertyId !== nextPropertyId) {
455
+ setFocusedPropertyId(nextPropertyId)
456
+ }
457
+ return
458
+ }
459
+
460
+ if (
461
+ focusedPropertyId &&
462
+ properties.some((entry) => entry.id === focusedPropertyId)
463
+ ) {
464
+ return
465
+ }
466
+
467
+ const firstAnimatedTrack = animatedProperties[0]?.track ?? null
468
+
469
+ if (firstAnimatedTrack) {
470
+ setSelected(firstAnimatedTrack.id)
471
+ setFocusedPropertyId(getPropertyId(firstAnimatedTrack.binding))
472
+ return
473
+ }
474
+
475
+ setFocusedPropertyId(properties[0]?.id ?? null)
476
+ }, [
477
+ animatedProperties,
478
+ focusedPropertyId,
479
+ layerTracks,
480
+ properties,
481
+ selectedLayer,
482
+ selectedTrackId,
483
+ setSelected,
484
+ timelinePanelOpen,
485
+ ])
486
+
487
+ useEffect(() => {
488
+ const updateViewportSize = () => {
489
+ setViewportSize({
490
+ height: window.innerHeight,
491
+ width: window.innerWidth,
492
+ })
493
+ }
494
+
495
+ updateViewportSize()
496
+ window.addEventListener("resize", updateViewportSize)
497
+
498
+ return () => {
499
+ window.removeEventListener("resize", updateViewportSize)
500
+ }
501
+ }, [])
502
+
503
+ useEffect(() => {
504
+ if (!timelinePanelOpen) {
505
+ return
506
+ }
507
+
508
+ const handleKeyDown = (event: KeyboardEvent) => {
509
+ if (
510
+ (event.key === "Backspace" || event.key === "Delete") &&
511
+ selectedTrackId &&
512
+ selectedKeyframeId &&
513
+ !isEditableTarget(event.target)
514
+ ) {
515
+ event.preventDefault()
516
+ removeKeyframe(selectedTrackId, selectedKeyframeId)
517
+ return
518
+ }
519
+
520
+ if (event.key === "Escape") {
521
+ closeTimelinePanel()
522
+ }
523
+ }
524
+
525
+ window.addEventListener("keydown", handleKeyDown)
526
+
527
+ return () => {
528
+ window.removeEventListener("keydown", handleKeyDown)
529
+ }
530
+ }, [
531
+ closeTimelinePanel,
532
+ removeKeyframe,
533
+ selectedKeyframeId,
534
+ selectedTrackId,
535
+ timelinePanelOpen,
536
+ ])
537
+
538
+ const getTimeFromClientX = useEffectEvent((clientX: number) => {
539
+ const surface = scrubSurfaceRef.current
540
+
541
+ if (!surface) {
542
+ return currentTime
543
+ }
544
+
545
+ const rect = surface.getBoundingClientRect()
546
+ const progress =
547
+ rect.width > 0 ? clamp((clientX - rect.left) / rect.width, 0, 1) : 0
548
+ return progress * duration
549
+ })
550
+
551
+ const handleDragMove = useEffectEvent((event: PointerEvent) => {
552
+ if (!dragState) {
553
+ return
554
+ }
555
+
556
+ const nextTime = getTimeFromClientX(event.clientX)
557
+
558
+ if (dragState.type === "playhead") {
559
+ setCurrentTime(nextTime)
560
+ return
561
+ }
562
+
563
+ setKeyframeTime(dragState.trackId, dragState.keyframeId, nextTime)
564
+ })
565
+
566
+ const handleDragEnd = useEffectEvent(() => {
567
+ setDragState(null)
568
+ })
569
+
570
+ useEffect(() => {
571
+ if (!dragState) {
572
+ return
573
+ }
574
+
575
+ window.addEventListener("pointermove", handleDragMove)
576
+ window.addEventListener("pointerup", handleDragEnd)
577
+ window.addEventListener("pointercancel", handleDragEnd)
578
+
579
+ return () => {
580
+ window.removeEventListener("pointermove", handleDragMove)
581
+ window.removeEventListener("pointerup", handleDragEnd)
582
+ window.removeEventListener("pointercancel", handleDragEnd)
583
+ }
584
+ }, [dragState])
585
+
586
+ if (immersiveCanvas) {
587
+ return null
588
+ }
589
+
590
+ const selectedTrack =
591
+ layerTracks.find((track) => track.id === selectedTrackId) ?? null
592
+ const progress = duration > 0 ? clamp(currentTime / duration, 0, 1) : 0
593
+ const shellWidth = timelinePanelOpen
594
+ ? Math.min(EXPANDED_SHELL_WIDTH, Math.max(640, viewportSize.width - 96))
595
+ : Math.min(COLLAPSED_SHELL_WIDTH, Math.max(360, viewportSize.width - 48))
596
+ const shellHeight = timelinePanelOpen
597
+ ? Math.min(EXPANDED_SHELL_HEIGHT, Math.max(220, viewportSize.height - 268))
598
+ : COLLAPSED_SHELL_HEIGHT
599
+ const expandedBodyHeight = Math.max(0, shellHeight - COLLAPSED_SHELL_HEIGHT)
600
+
601
+ const handleScrubStart = (event: ReactPointerEvent<HTMLDivElement>) => {
602
+ event.preventDefault()
603
+ setCurrentTime(getTimeFromClientX(event.clientX))
604
+ setDragState({ type: "playhead" })
605
+ }
606
+
607
+ let panelBodyAnimation: {
608
+ height: number
609
+ opacity: number
610
+ y?: number
611
+ }
612
+
613
+ if (timelinePanelOpen) {
614
+ panelBodyAnimation = reduceMotion
615
+ ? { height: expandedBodyHeight, opacity: 1 }
616
+ : { height: expandedBodyHeight, opacity: 1, y: 0 }
617
+ } else {
618
+ panelBodyAnimation = reduceMotion
619
+ ? { height: 0, opacity: 0 }
620
+ : { height: 0, opacity: 0, y: 8 }
621
+ }
622
+
623
+ return (
624
+ <div className="pointer-events-none fixed right-0 bottom-3 left-0 z-35 flex justify-center">
625
+ <motion.div
626
+ animate={
627
+ reduceMotion
628
+ ? { height: shellHeight, opacity: 1, width: shellWidth }
629
+ : { height: shellHeight, opacity: 1, width: shellWidth, y: 0 }
630
+ }
631
+ className="pointer-events-auto mx-auto max-h-[min(380px,calc(100vh-268px))] origin-bottom"
632
+ initial={false}
633
+ transition={
634
+ reduceMotion
635
+ ? { duration: 0.14, ease: "easeOut" }
636
+ : {
637
+ damping: 34,
638
+ mass: 0.95,
639
+ stiffness: 280,
640
+ type: "spring",
641
+ }
642
+ }
643
+ >
644
+ <GlassPanel
645
+ className="pointer-events-auto flex h-full max-h-inherit w-full flex-col overflow-hidden"
646
+ variant="panel"
647
+ >
648
+ <div
649
+ className={cn(
650
+ "border-b border-[var(--ds-border-divider)] p-2 transition-[border-color] duration-160 ease-[var(--ease-out-cubic)]",
651
+ !timelinePanelOpen && "border-b-transparent"
652
+ )}
653
+ >
654
+ <TimelineTransport
655
+ currentTime={currentTime}
656
+ duration={duration}
657
+ expanded={timelinePanelOpen}
658
+ isPlaying={isPlaying}
659
+ loop={loop}
660
+ onDurationChange={setDuration}
661
+ onStop={stop}
662
+ onToggleExpanded={toggleTimelinePanel}
663
+ onToggleLoop={() => setLoop(!loop)}
664
+ onTogglePlaying={togglePlaying}
665
+ />
666
+ </div>
667
+
668
+ <motion.div
669
+ animate={panelBodyAnimation}
670
+ className="flex min-h-0 flex-1 overflow-hidden"
671
+ initial={false}
672
+ transition={
673
+ reduceMotion
674
+ ? { duration: 0.12, ease: "easeOut" }
675
+ : {
676
+ damping: 34,
677
+ delay: timelinePanelOpen ? 0.04 : 0,
678
+ mass: 0.78,
679
+ stiffness: 320,
680
+ type: "spring",
681
+ }
682
+ }
683
+ >
684
+ <div
685
+ aria-hidden={!timelinePanelOpen}
686
+ className={cn(
687
+ "flex h-full min-h-0 flex-1 overflow-hidden",
688
+ !timelinePanelOpen && "pointer-events-none"
689
+ )}
690
+ >
691
+ <div className="flex h-full min-h-0 shrink-0 basis-[180px] flex-col gap-4 overflow-y-auto border-r border-[var(--ds-border-divider)] px-3 pt-[10px] pb-3 [scrollbar-gutter:stable]">
692
+ <div className="flex flex-col gap-[10px]">
693
+ <Typography
694
+ className="tracking-[0.08em] uppercase"
695
+ tone="secondary"
696
+ variant="overline"
697
+ >
698
+ Properties
699
+ </Typography>
700
+
701
+ <div className="flex flex-col gap-1.5">
702
+ {properties.length > 0 ? (
703
+ properties.map((entry) => {
704
+ const isFocused = focusedPropertyId === entry.id
705
+ const hasTrack = Boolean(entry.track)
706
+
707
+ return (
708
+ <button
709
+ className={cn(
710
+ "flex min-h-8 items-center gap-[10px] rounded-[10px] border border-transparent px-[10px] text-left transition-[background-color,border-color,color,transform] duration-160 ease-[var(--ease-out-cubic)] hover:bg-white/4 hover:border-white/5 active:scale-[0.995]",
711
+ isFocused && "border-white/8 bg-white/8",
712
+ hasTrack
713
+ ? "text-[var(--ds-color-text-primary)]"
714
+ : "text-[var(--ds-color-text-muted)]"
715
+ )}
716
+ key={entry.id}
717
+ onClick={() => {
718
+ setFocusedPropertyId(entry.id)
719
+
720
+ if (entry.track) {
721
+ setSelected(entry.track.id)
722
+ } else {
723
+ setSelected(null)
724
+ }
725
+ }}
726
+ type="button"
727
+ >
728
+ <div className="flex min-w-0 items-center gap-2">
729
+ <span
730
+ aria-hidden="true"
731
+ className="h-2 w-2 shrink-0 rounded-full shadow-[0_0_0_1px_rgb(255_255_255_/_0.08)]"
732
+ style={{ backgroundColor: entry.color }}
733
+ />
734
+ <Typography
735
+ as="span"
736
+ className="min-w-0"
737
+ tone={hasTrack ? "primary" : "muted"}
738
+ variant="monoSm"
739
+ >
740
+ {entry.label}
741
+ </Typography>
742
+ </div>
743
+ <span
744
+ aria-hidden="true"
745
+ className={cn(
746
+ "inline-flex h-[7px] w-[7px] shrink-0 rounded-full",
747
+ hasTrack
748
+ ? "bg-[rgb(var(--timeline-track-rgb,122_162_255)_/_0.9)] shadow-[0_0_10px_rgb(var(--timeline-track-rgb,122_162_255)_/_0.35)]"
749
+ : "bg-white/14"
750
+ )}
751
+ style={
752
+ hasTrack
753
+ ? ({
754
+ "--timeline-track-rgb": hexToRgbChannels(
755
+ entry.color
756
+ ),
757
+ } as CSSProperties)
758
+ : undefined
759
+ }
760
+ />
761
+ </button>
762
+ )
763
+ })
764
+ ) : (
765
+ <Typography tone="muted" variant="caption">
766
+ Select a layer to inspect its timeline properties.
767
+ </Typography>
768
+ )}
769
+ </div>
770
+ </div>
771
+ </div>
772
+
773
+ <div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
774
+ <div
775
+ className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
776
+ onPointerDown={handleScrubStart}
777
+ ref={scrubSurfaceRef}
778
+ >
779
+ <div className="relative basis-[30px] border-b border-[var(--ds-border-divider)]">
780
+ {tickPositions.minorTicks.map((tick) => (
781
+ <span
782
+ aria-hidden="true"
783
+ className="absolute bottom-0 w-px bg-white/6 h-[10px]"
784
+ key={`minor-${tick}`}
785
+ style={{ left: `${(tick / duration) * 100}%` }}
786
+ />
787
+ ))}
788
+
789
+ {tickPositions.majorTicks.map((tick) => (
790
+ <span
791
+ aria-hidden="true"
792
+ className="absolute bottom-0 h-[18px] w-px bg-white/14"
793
+ key={`major-${tick}`}
794
+ style={{ left: `${(tick / duration) * 100}%` }}
795
+ />
796
+ ))}
797
+
798
+ {tickPositions.majorTicks.map((tick) => (
799
+ <Typography
800
+ as="span"
801
+ className="absolute top-1 left-0 -translate-x-1/2 whitespace-nowrap"
802
+ key={`label-${tick}`}
803
+ tone="muted"
804
+ variant="monoXs"
805
+ style={{ left: `${(tick / duration) * 100}%` }}
806
+ >
807
+ {tick.toFixed(1)}
808
+ </Typography>
809
+ ))}
810
+ </div>
811
+
812
+ <div className="relative flex min-h-0 flex-1 flex-col overflow-y-auto">
813
+ {animatedProperties.length > 0 ? (
814
+ animatedProperties.map((entry) => {
815
+ const track = entry.track
816
+
817
+ if (!track) {
818
+ return null
819
+ }
820
+
821
+ const isFocused = focusedPropertyId === entry.id
822
+
823
+ return (
824
+ <div
825
+ className={cn(
826
+ "relative basis-[46px] border-b border-white/4 bg-[linear-gradient(90deg,rgb(255_255_255_/_0.02)_0%,rgb(255_255_255_/_0.015)_100%)]",
827
+ isFocused &&
828
+ "bg-[linear-gradient(90deg,rgb(var(--timeline-track-rgb,122_162_255)_/_0.12)_0%,rgb(var(--timeline-track-rgb,122_162_255)_/_0.03)_42%,rgb(255_255_255_/_0.02)_100%)]"
829
+ )}
830
+ key={track.id}
831
+ style={
832
+ {
833
+ "--timeline-track-rgb": hexToRgbChannels(
834
+ entry.color
835
+ ),
836
+ } as CSSProperties
837
+ }
838
+ >
839
+ <div
840
+ className={cn(
841
+ "absolute top-[22px] right-0 left-0 h-0.5 rounded-full bg-[rgb(var(--timeline-track-rgb,122_162_255)_/_0.18)]",
842
+ !track.enabled && "opacity-40"
843
+ )}
844
+ />
845
+ {track.keyframes.map((keyframe) => (
846
+ <button
847
+ aria-label={`Keyframe at ${formatSeconds(keyframe.time)}`}
848
+ className="group absolute top-[11px] inline-flex h-[22px] w-[22px] -translate-x-1/2 items-center justify-center bg-transparent p-0 text-inherit cursor-grab active:cursor-grabbing"
849
+ data-selected={selectedKeyframeId === keyframe.id}
850
+ key={keyframe.id}
851
+ onPointerDown={(event) => {
852
+ event.preventDefault()
853
+ event.stopPropagation()
854
+ setFocusedPropertyId(entry.id)
855
+ setSelected(track.id, keyframe.id)
856
+ setDragState({
857
+ keyframeId: keyframe.id,
858
+ trackId: track.id,
859
+ type: "keyframe",
860
+ })
861
+ }}
862
+ style={{
863
+ left: `${(keyframe.time / duration) * 100}%`,
864
+ }}
865
+ type="button"
866
+ >
867
+ <span
868
+ aria-hidden="true"
869
+ className={cn(
870
+ "h-[11px] w-[11px] rounded-[4px] border border-white/40 bg-[rgb(var(--timeline-track-rgb,122_162_255)_/_0.95)] shadow-[0_4px_10px_rgb(0_0_0_/_0.22)] rotate-45 transition-[box-shadow,transform] duration-160 ease-[var(--ease-out-cubic)] group-hover:shadow-[0_0_0_1px_rgb(255_255_255_/_0.24),0_6px_14px_rgb(0_0_0_/_0.28)]",
871
+ selectedKeyframeId === keyframe.id &&
872
+ "bg-[rgb(var(--timeline-track-rgb,122_162_255)_/_1)] scale-[1.12]"
873
+ )}
874
+ />
875
+ </button>
876
+ ))}
877
+ </div>
878
+ )
879
+ })
880
+ ) : (
881
+ <div className="pointer-events-none absolute inset-x-0 top-[30px] flex items-start justify-center">
882
+ <div className="flex max-w-[320px] flex-col gap-1.5 px-[18px] py-4 text-center">
883
+ <Typography
884
+ align="center"
885
+ variant="caption"
886
+ className="text-balance"
887
+ >
888
+ Add your first keyframe from the properties panel.
889
+ </Typography>
890
+ </div>
891
+ </div>
892
+ )}
893
+
894
+ <div
895
+ className="pointer-events-none absolute top-0 bottom-0 w-0 -translate-x-1/2"
896
+ style={{ left: `${progress * 100}%` }}
897
+ >
898
+ <div
899
+ aria-hidden="true"
900
+ className="pointer-events-auto absolute top-0 left-1/2 h-[14px] w-[14px] -translate-x-1/2 rounded-[4px] bg-white/96 shadow-[0_8px_18px_rgb(0_0_0_/_0.28)]"
901
+ onPointerDown={(event) => {
902
+ event.preventDefault()
903
+ event.stopPropagation()
904
+ setDragState({ type: "playhead" })
905
+ }}
906
+ />
907
+ <div
908
+ aria-hidden="true"
909
+ className="absolute top-3 bottom-0 left-1/2 w-px -translate-x-1/2 bg-[linear-gradient(180deg,rgb(255_255_255_/_0.95)_0%,rgb(255_255_255_/_0.62)_100%)]"
910
+ />
911
+ </div>
912
+ </div>
913
+
914
+ </div>
915
+
916
+ {selectedTrack ? (
917
+ <div
918
+ className="pointer-events-auto absolute right-3 bottom-3 z-4 inline-flex"
919
+ onPointerDown={(event) => {
920
+ event.stopPropagation()
921
+ }}
922
+ >
923
+ <BaseSelect.Root
924
+ items={INTERPOLATION_OPTIONS}
925
+ modal={false}
926
+ onValueChange={(value) => {
927
+ if (value) {
928
+ setTrackInterpolation(
929
+ selectedTrack.id,
930
+ value as TimelineInterpolation
931
+ )
932
+ }
933
+ }}
934
+ value={selectedTrack.interpolation}
935
+ >
936
+ <BaseSelect.Trigger
937
+ aria-label="Track easing"
938
+ className="inline-flex h-7 w-7 items-center justify-center rounded-[var(--ds-radius-icon)] border border-[var(--ds-border-divider)] bg-[var(--ds-color-surface-control)] text-[var(--ds-color-text-secondary)] transition-[background-color,border-color,color,transform] duration-160 ease-[var(--ease-out-cubic)] hover:bg-white/8 hover:border-[var(--ds-border-hover)] active:scale-[0.96] data-[popup-open]:bg-white/8 data-[popup-open]:border-[var(--ds-border-hover)] data-[popup-open]:text-[var(--ds-color-text-primary)] focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-border-active)]"
939
+ onPointerDown={(event) => {
940
+ event.stopPropagation()
941
+ }}
942
+ >
943
+ <BezierCurveIcon size={14} weight="bold" />
944
+ </BaseSelect.Trigger>
945
+
946
+ <BaseSelect.Portal>
947
+ <BaseSelect.Positioner
948
+ align="end"
949
+ alignItemWithTrigger={false}
950
+ className="z-50 outline-none"
951
+ side="top"
952
+ sideOffset={10}
953
+ >
954
+ <BaseSelect.Popup className="min-w-[132px] overflow-hidden rounded-[12px] border border-[var(--ds-border-panel)] bg-[rgb(18_18_22_/_0.82)] shadow-[var(--ds-shadow-panel-dark)] backdrop-blur-[24px]">
955
+ <BaseSelect.List className="flex flex-col gap-0.5 p-1">
956
+ {INTERPOLATION_OPTIONS.map((option) => (
957
+ <BaseSelect.Item
958
+ className="cursor-pointer rounded-[var(--ds-radius-icon)] px-[10px] py-[6px] text-[var(--ds-color-text-secondary)] outline-none transition-[background-color,color] duration-140 ease-[var(--ease-out-cubic)] data-[highlighted]:bg-[var(--ds-color-surface-active)] data-[selected]:bg-[var(--ds-color-surface-active)] data-[highlighted]:text-[var(--ds-color-text-primary)] data-[selected]:text-[var(--ds-color-text-primary)]"
959
+ key={option.value}
960
+ value={option.value}
961
+ >
962
+ <BaseSelect.ItemText
963
+ className="block font-[var(--ds-font-mono)] text-[11px] leading-[14px]"
964
+ >
965
+ {option.label}
966
+ </BaseSelect.ItemText>
967
+ </BaseSelect.Item>
968
+ ))}
969
+ </BaseSelect.List>
970
+ </BaseSelect.Popup>
971
+ </BaseSelect.Positioner>
972
+ </BaseSelect.Portal>
973
+ </BaseSelect.Root>
974
+ </div>
975
+ ) : null}
976
+ </div>
977
+ </div>
978
+ </motion.div>
979
+ </GlassPanel>
980
+ </motion.div>
981
+ </div>
982
+ )
983
+ }