@hyperframes/studio 0.7.39 → 0.7.41

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 (76) hide show
  1. package/dist/assets/{index-CQHiecE7.js → index-BCG-TnJO.js} +1 -1
  2. package/dist/assets/index-BpM6cnfP.css +1 -0
  3. package/dist/assets/{index-B9YvRJz1.js → index-DRrNf6k_.js} +195 -195
  4. package/dist/assets/{index-312a3Ceu.js → index-rnqIbAs_.js} +1 -1
  5. package/dist/{chunk-RCLGSZ7C.js → chunk-BA66NM4L.js} +1 -1
  6. package/dist/{chunk-RCLGSZ7C.js.map → chunk-BA66NM4L.js.map} +1 -1
  7. package/dist/{domEditingLayers-S6YOLVRG.js → domEditingLayers-H7LDFIJ7.js} +2 -2
  8. package/dist/index.d.ts +34 -29
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +2749 -2075
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +2 -1
  14. package/src/components/StudioPreviewArea.tsx +6 -0
  15. package/src/components/StudioRightPanel.tsx +3 -0
  16. package/src/components/editor/DomEditOverlay.tsx +33 -47
  17. package/src/components/editor/PropertyPanel.test.ts +25 -0
  18. package/src/components/editor/PropertyPanel.tsx +33 -7
  19. package/src/components/editor/domEditingElement.ts +8 -1
  20. package/src/components/editor/manualEditsDom.ts +1 -18
  21. package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
  22. package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
  23. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
  24. package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
  25. package/src/components/editor/propertyPanelHelpers.ts +11 -0
  26. package/src/components/editor/propertyPanelTypes.ts +1 -0
  27. package/src/contexts/TimelineEditContext.tsx +1 -0
  28. package/src/hooks/gsapDragCommit.test.ts +56 -0
  29. package/src/hooks/gsapDragCommit.ts +41 -109
  30. package/src/hooks/gsapDragPositionCommit.ts +3 -0
  31. package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
  32. package/src/hooks/gsapRuntimeBridge.ts +23 -2
  33. package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
  34. package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
  35. package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
  36. package/src/hooks/timelineEditingHelpers.ts +17 -5
  37. package/src/hooks/timelineTrackVisibility.test.ts +202 -0
  38. package/src/hooks/timelineTrackVisibility.ts +371 -0
  39. package/src/hooks/useRenderClipContent.test.ts +68 -2
  40. package/src/hooks/useRenderClipContent.ts +5 -6
  41. package/src/hooks/useTimelineEditing.ts +35 -30
  42. package/src/hooks/useTimelineEditingTypes.ts +30 -0
  43. package/src/icons/SystemIcons.tsx +12 -4
  44. package/src/player/components/AudioWaveform.tsx +11 -8
  45. package/src/player/components/CompositionThumbnail.tsx +14 -11
  46. package/src/player/components/PlayheadIndicator.tsx +21 -11
  47. package/src/player/components/Timeline.test.ts +105 -5
  48. package/src/player/components/Timeline.tsx +37 -47
  49. package/src/player/components/TimelineCanvas.tsx +275 -256
  50. package/src/player/components/TimelineClip.test.tsx +105 -0
  51. package/src/player/components/TimelineClip.tsx +44 -57
  52. package/src/player/components/TimelineRuler.tsx +17 -23
  53. package/src/player/components/TimelineShortcutHint.tsx +26 -0
  54. package/src/player/components/TimelineTypes.ts +18 -0
  55. package/src/player/components/VideoThumbnail.tsx +16 -13
  56. package/src/player/components/timelineCallbacks.ts +1 -0
  57. package/src/player/components/timelineIcons.tsx +2 -44
  58. package/src/player/components/timelineLayout.ts +18 -22
  59. package/src/player/components/timelineMotionStyles.test.ts +101 -0
  60. package/src/player/components/timelineTheme.test.ts +21 -4
  61. package/src/player/components/timelineTheme.ts +15 -15
  62. package/src/player/components/useTimelineActiveClips.test.ts +93 -0
  63. package/src/player/components/useTimelineActiveClips.ts +125 -0
  64. package/src/player/components/useTimelinePlayhead.ts +6 -0
  65. package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
  66. package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
  67. package/src/player/lib/time.test.ts +1 -0
  68. package/src/player/lib/timelineDOM.test.ts +48 -1
  69. package/src/player/lib/timelineDOM.ts +7 -0
  70. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  71. package/src/player/store/playerStore.ts +5 -1
  72. package/src/styles/studio.css +108 -0
  73. package/src/utils/sourcePatcher.test.ts +11 -0
  74. package/src/utils/sourcePatcher.ts +6 -4
  75. package/dist/assets/index-0P10SwC_.css +0 -1
  76. /package/dist/{domEditingLayers-S6YOLVRG.js.map → domEditingLayers-H7LDFIJ7.js.map} +0 -0
@@ -0,0 +1,105 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import type { TimelineElement } from "../store/playerStore";
7
+ import { TimelineClip } from "./TimelineClip";
8
+ import type { TimelineEditCapabilities } from "./timelineEditing";
9
+
10
+ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
11
+ configurable: true,
12
+ value: true,
13
+ });
14
+
15
+ afterEach(() => {
16
+ document.body.innerHTML = "";
17
+ });
18
+
19
+ const capabilities: TimelineEditCapabilities = {
20
+ canMove: true,
21
+ canTrimStart: true,
22
+ canTrimEnd: true,
23
+ };
24
+
25
+ function renderClip({
26
+ element,
27
+ pps = 100,
28
+ isSelected = false,
29
+ hasCustomContent = true,
30
+ }: {
31
+ element: TimelineElement;
32
+ pps?: number;
33
+ isSelected?: boolean;
34
+ hasCustomContent?: boolean;
35
+ }) {
36
+ const host = document.createElement("div");
37
+ document.body.append(host);
38
+ const root = createRoot(host);
39
+
40
+ act(() => {
41
+ root.render(
42
+ <TimelineClip
43
+ el={element}
44
+ pps={pps}
45
+ clipY={0}
46
+ isSelected={isSelected}
47
+ isHovered={false}
48
+ hasCustomContent={hasCustomContent}
49
+ capabilities={capabilities}
50
+ isComposition={false}
51
+ onHoverStart={vi.fn()}
52
+ onHoverEnd={vi.fn()}
53
+ onClick={vi.fn()}
54
+ onDoubleClick={vi.fn()}
55
+ >
56
+ <div data-custom-content="true" />
57
+ </TimelineClip>,
58
+ );
59
+ });
60
+
61
+ return { host, root };
62
+ }
63
+
64
+ describe("TimelineClip", () => {
65
+ it("renders the clip label above custom content without showing default timecode", () => {
66
+ const { host, root } = renderClip({
67
+ element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 0.5, track: 0 },
68
+ });
69
+
70
+ expect(host.querySelector(".timeline-clip__label")?.textContent).toBe("Hero");
71
+ expect(host.querySelector(".timeline-clip__timecode")).toBeNull();
72
+
73
+ act(() => root.unmount());
74
+ });
75
+
76
+ it("keeps selected narrow clips labeled even when they render custom content", () => {
77
+ const { host, root } = renderClip({
78
+ element: { id: "fx", label: "FX", tag: "div", start: 0, duration: 0.1, track: 0 },
79
+ isSelected: true,
80
+ });
81
+
82
+ expect(host.querySelector(".timeline-clip__label")?.textContent).toBe("FX");
83
+ expect(host.querySelector(".timeline-clip__timecode")).toBeNull();
84
+
85
+ act(() => root.unmount());
86
+ });
87
+
88
+ it("marks hidden clips for active-state suppression", () => {
89
+ const { host, root } = renderClip({
90
+ element: {
91
+ id: "hidden",
92
+ label: "Hidden",
93
+ tag: "div",
94
+ start: 0,
95
+ duration: 1,
96
+ track: 0,
97
+ hidden: true,
98
+ },
99
+ });
100
+
101
+ expect(host.querySelector(".timeline-clip")?.getAttribute("data-clip-hidden")).toBe("true");
102
+
103
+ act(() => root.unmount());
104
+ });
105
+ });
@@ -1,6 +1,4 @@
1
- import type { TimelineTrackStyle } from "./timelineTheme";
2
-
3
- import { memo, type ReactNode } from "react";
1
+ import { memo, type CSSProperties, type ReactNode } from "react";
4
2
  import type { TimelineElement } from "../store/playerStore";
5
3
  import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "./timelineTheme";
6
4
  import type { TimelineEditCapabilities } from "./timelineEditing";
@@ -15,7 +13,6 @@ interface TimelineClipProps {
15
13
  hasCustomContent: boolean;
16
14
  capabilities: TimelineEditCapabilities;
17
15
  theme?: TimelineTheme;
18
- trackStyle: TimelineTrackStyle;
19
16
  isComposition: boolean;
20
17
  onHoverStart: () => void;
21
18
  onHoverEnd: () => void;
@@ -27,6 +24,7 @@ interface TimelineClipProps {
27
24
  children?: ReactNode;
28
25
  }
29
26
 
27
+ // fallow-ignore-next-line complexity
30
28
  export const TimelineClip = memo(function TimelineClip({
31
29
  el,
32
30
  pps,
@@ -37,7 +35,6 @@ export const TimelineClip = memo(function TimelineClip({
37
35
  hasCustomContent,
38
36
  capabilities,
39
37
  theme = defaultTimelineTheme,
40
- trackStyle,
41
38
  isComposition,
42
39
  onHoverStart,
43
40
  onHoverEnd,
@@ -51,45 +48,43 @@ export const TimelineClip = memo(function TimelineClip({
51
48
  const leftPx = el.start * pps;
52
49
  const widthPx = Math.max(el.duration * pps, 4);
53
50
  const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
54
-
55
- const borderColor = isSelected
56
- ? trackStyle.accent
57
- : isHovered
58
- ? theme.clipBorderHover
59
- : theme.clipBorder;
60
- const boxShadow = isDragging
61
- ? theme.clipShadowDragging
62
- : isSelected
63
- ? `0 0 0 1px ${trackStyle.accent}80, 0 0 8px ${trackStyle.accent}25`
64
- : isHovered
65
- ? theme.clipShadowHover
66
- : theme.clipShadow;
67
51
  const displayLabel = el.label || el.id || el.tag;
68
- const showHandles = handleOpacity > 0.01;
52
+ const showHandles = handleOpacity > 0.01 && (widthPx >= 32 || isSelected);
53
+ const showLabel = widthPx >= 40 || isSelected;
54
+ const showDefaultText = !hasCustomContent && (widthPx >= 40 || isSelected);
55
+ const startLabel = el.start.toFixed(1);
56
+ const endLabel = (el.start + el.duration).toFixed(1);
57
+ const clipClassName = [
58
+ "timeline-clip",
59
+ "absolute",
60
+ hasCustomContent ? "overflow-visible" : "overflow-hidden",
61
+ isSelected ? "is-selected" : "",
62
+ isHovered ? "is-hovered" : "",
63
+ isDragging ? "is-dragging" : "",
64
+ showDefaultText ? "" : "is-micro",
65
+ ]
66
+ .filter((className) => className.length > 0)
67
+ .join(" ");
68
+ const style: CSSProperties = {
69
+ left: leftPx,
70
+ width: widthPx,
71
+ top: clipY,
72
+ bottom: clipY,
73
+ borderRadius: theme.clipRadius,
74
+ zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
75
+ cursor: capabilities.canMove ? "grab" : "default",
76
+ transform: isDragging ? "translateY(-1px)" : undefined,
77
+ };
69
78
 
70
79
  return (
71
80
  <div
72
81
  data-clip="true"
73
- className={
74
- hasCustomContent
75
- ? "absolute overflow-visible"
76
- : "absolute flex items-center overflow-visible"
77
- }
78
- style={{
79
- left: leftPx,
80
- width: widthPx,
81
- top: clipY,
82
- bottom: clipY,
83
- borderRadius: theme.clipRadius,
84
- background: trackStyle.clip,
85
- border: `1px solid ${borderColor}`,
86
- boxShadow,
87
- transition: "border-color 100ms, box-shadow 100ms",
88
- zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
89
- cursor: capabilities.canMove ? "grab" : "default",
90
- transform: isDragging ? "translateY(-1px)" : undefined,
91
- opacity: isDragging ? 0.92 : 1,
92
- }}
82
+ data-el-id={el.key ?? el.id}
83
+ data-clip-start={el.start}
84
+ data-clip-end={el.start + el.duration}
85
+ data-clip-hidden={el.hidden ? "true" : undefined}
86
+ className={clipClassName}
87
+ style={style}
93
88
  title={
94
89
  isComposition
95
90
  ? `${el.compositionSrc} • Double-click to open`
@@ -102,22 +97,6 @@ export const TimelineClip = memo(function TimelineClip({
102
97
  onDoubleClick={onDoubleClick}
103
98
  onContextMenu={onContextMenu}
104
99
  >
105
- {/* Left accent stripe — wider + brighter for expanded sub-comp children */}
106
- <div
107
- aria-hidden="true"
108
- style={{
109
- position: "absolute",
110
- left: 0,
111
- top: 0,
112
- bottom: 0,
113
- width: el.expandedParentStart !== undefined ? 4 : 3,
114
- background: trackStyle.accent,
115
- opacity: el.expandedParentStart !== undefined ? 0.8 : isSelected ? 0.7 : 0.3,
116
- borderRadius: `${theme.clipRadius} 0 0 ${theme.clipRadius}`,
117
- zIndex: 2,
118
- pointerEvents: "none",
119
- }}
120
- />
121
100
  {/* Left trim handle */}
122
101
  {showHandles && capabilities.canTrimStart && (
123
102
  <div
@@ -134,6 +113,7 @@ export const TimelineClip = memo(function TimelineClip({
134
113
  }}
135
114
  >
136
115
  <div
116
+ className="timeline-clip__handle-bar"
137
117
  style={{
138
118
  position: "absolute",
139
119
  left: 4,
@@ -141,7 +121,7 @@ export const TimelineClip = memo(function TimelineClip({
141
121
  bottom: 6,
142
122
  width: 2,
143
123
  borderRadius: 1,
144
- background: trackStyle.accent,
124
+ background: "rgba(255, 255, 255, 0.55)",
145
125
  opacity: handleOpacity * 0.6,
146
126
  }}
147
127
  />
@@ -163,6 +143,7 @@ export const TimelineClip = memo(function TimelineClip({
163
143
  }}
164
144
  >
165
145
  <div
146
+ className="timeline-clip__handle-bar"
166
147
  style={{
167
148
  position: "absolute",
168
149
  right: 4,
@@ -170,12 +151,18 @@ export const TimelineClip = memo(function TimelineClip({
170
151
  bottom: 6,
171
152
  width: 2,
172
153
  borderRadius: 1,
173
- background: trackStyle.accent,
154
+ background: "rgba(255, 255, 255, 0.55)",
174
155
  opacity: handleOpacity * 0.6,
175
156
  }}
176
157
  />
177
158
  </div>
178
159
  )}
160
+ {showLabel && <span className="timeline-clip__label">{displayLabel}</span>}
161
+ {showDefaultText && (
162
+ <span className="timeline-clip__timecode">
163
+ {startLabel}-{endLabel}s
164
+ </span>
165
+ )}
179
166
  {children}
180
167
  </div>
181
168
  );
@@ -1,6 +1,5 @@
1
1
  import { memo } from "react";
2
2
  import type { TimelineTheme } from "./timelineTheme";
3
- import type { TimelineRangeSelection } from "./timelineEditing";
4
3
  import { GUTTER, RULER_H, formatTimelineTickLabel } from "./timelineLayout";
5
4
  import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
6
5
 
@@ -12,8 +11,6 @@ interface TimelineRulerProps {
12
11
  totalH: number;
13
12
  effectiveDuration: number;
14
13
  majorTickInterval: number;
15
- shiftHeld: boolean;
16
- rangeSelection: TimelineRangeSelection | null;
17
14
  theme: TimelineTheme;
18
15
  beatAnalysis?: MusicBeatAnalysis | null;
19
16
  }
@@ -26,8 +23,6 @@ export const TimelineRuler = memo(function TimelineRuler({
26
23
  totalH,
27
24
  effectiveDuration,
28
25
  majorTickInterval,
29
- shiftHeld,
30
- rangeSelection,
31
26
  theme,
32
27
  beatAnalysis,
33
28
  }: TimelineRulerProps) {
@@ -87,35 +82,34 @@ export const TimelineRuler = memo(function TimelineRuler({
87
82
  {/* Ruler */}
88
83
  <div
89
84
  className="relative overflow-hidden"
90
- style={{ height: RULER_H, marginLeft: GUTTER, width: trackContentWidth }}
85
+ style={{
86
+ height: RULER_H,
87
+ marginLeft: GUTTER,
88
+ width: trackContentWidth,
89
+ background: theme.gutterBackground,
90
+ borderBottom: `1px solid ${theme.rulerBorder}`,
91
+ }}
91
92
  >
92
- {shiftHeld && !rangeSelection && (
93
- <div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
94
- <span className="text-[9px] font-medium" style={{ color: theme.textSecondary }}>
95
- Drag or click a clip to edit range
96
- </span>
97
- </div>
98
- )}
99
-
100
93
  {minor.map((t) => (
101
94
  <div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
102
- <div className="w-px h-[3px]" style={{ background: theme.tickMinor }} />
95
+ <div className="w-px h-2" style={{ background: theme.tickMinor }} />
103
96
  </div>
104
97
  ))}
105
98
 
106
99
  {major.map((t) => (
107
- <div
108
- key={`M-${t}`}
109
- className="absolute bottom-0 flex flex-col items-center"
110
- style={{ left: t * pps }}
111
- >
100
+ <div key={`M-${t}`} className="absolute top-0" style={{ left: t * pps }}>
112
101
  <span
113
- className="text-[9px] font-mono tabular-nums leading-none mb-0.5"
114
- style={{ color: theme.tickText }}
102
+ className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
103
+ style={{
104
+ color: theme.tickText,
105
+ left: 5,
106
+ top: 5,
107
+ fontSize: 10,
108
+ }}
115
109
  >
116
110
  {formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
117
111
  </span>
118
- <div className="w-px h-[5px]" style={{ background: theme.tickMajor }} />
112
+ <div className="w-px" style={{ height: RULER_H, background: theme.tickMajor }} />
119
113
  </div>
120
114
  ))}
121
115
  </div>
@@ -0,0 +1,26 @@
1
+ import type { TimelineTheme } from "./timelineTheme";
2
+
3
+ interface TimelineShortcutHintProps {
4
+ theme: TimelineTheme;
5
+ }
6
+
7
+ export function TimelineShortcutHint({ theme }: TimelineShortcutHintProps) {
8
+ return (
9
+ <div className="absolute bottom-2 right-3 pointer-events-none z-20">
10
+ <div
11
+ className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
12
+ style={{ background: "rgba(17,23,35,0.84)", borderColor: theme.gutterBorder }}
13
+ >
14
+ <kbd
15
+ className="text-[9px] font-mono px-1 py-0.5 rounded"
16
+ style={{ color: theme.textSecondary, background: "rgba(255,255,255,0.06)" }}
17
+ >
18
+ Shift
19
+ </kbd>
20
+ <span className="text-[9px]" style={{ color: theme.textSecondary }}>
21
+ + drag/click to edit range
22
+ </span>
23
+ </div>
24
+ </div>
25
+ );
26
+ }
@@ -0,0 +1,18 @@
1
+ import type { ReactNode } from "react";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import type { TimelineDropCallbacks } from "./timelineCallbacks";
4
+ import type { TimelineTheme } from "./timelineTheme";
5
+ import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks";
6
+
7
+ export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides {
8
+ onSeek?: (time: number) => void;
9
+ onDrillDown?: (element: TimelineElement) => void;
10
+ renderClipContent?: (
11
+ element: TimelineElement,
12
+ style: { clip: string; label: string },
13
+ ) => ReactNode;
14
+ renderClipOverlay?: (element: TimelineElement) => ReactNode;
15
+ onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
16
+ onSelectElement?: (element: TimelineElement | null) => void;
17
+ theme?: Partial<TimelineTheme>;
18
+ }
@@ -47,6 +47,7 @@ export const VideoThumbnail = memo(function VideoThumbnail({
47
47
  },
48
48
  { rootMargin: "200px" },
49
49
  );
50
+ // fallow-ignore-next-line code-duplication
50
51
  ioRef.current.observe(el);
51
52
 
52
53
  const target = el.parentElement || el;
@@ -177,20 +178,22 @@ export const VideoThumbnail = memo(function VideoThumbnail({
177
178
  />
178
179
  )}
179
180
 
180
- <div
181
- className="absolute bottom-0 left-0 right-0 z-10 px-1.5 pb-0.5 pt-3"
182
- style={{
183
- background:
184
- "linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
185
- }}
186
- >
187
- <span
188
- className="text-[9px] font-semibold truncate block leading-tight"
189
- style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
181
+ {label && (
182
+ <div
183
+ className="absolute bottom-0 left-0 right-0 z-10 px-1.5 pb-0.5 pt-3"
184
+ style={{
185
+ background:
186
+ "linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
187
+ }}
190
188
  >
191
- {label}
192
- </span>
193
- </div>
189
+ <span
190
+ className="text-[9px] font-semibold truncate block leading-tight"
191
+ style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
192
+ >
193
+ {label}
194
+ </span>
195
+ </div>
196
+ )}
194
197
  </div>
195
198
  );
196
199
  });
@@ -32,6 +32,7 @@ export interface TimelineEditCallbacks {
32
32
  element: TimelineElement,
33
33
  updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
34
34
  ) => Promise<void> | void;
35
+ onToggleTrackHidden?: (track: number, hidden: boolean) => Promise<void> | void;
35
36
  onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
36
37
  onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
37
38
  onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
@@ -1,52 +1,10 @@
1
- import type { ReactNode } from "react";
2
1
  import { getTimelineTrackStyle, type TimelineTrackStyle } from "./timelineTheme";
3
2
 
4
- export interface TrackVisualStyle extends TimelineTrackStyle {
5
- icon: ReactNode;
6
- }
7
-
8
- const ICON_BASE = "/icons/timeline";
9
- function TimelineIcon({ src }: { src: string }) {
10
- return (
11
- <img
12
- src={src}
13
- alt=""
14
- width={12}
15
- height={12}
16
- style={{ filter: "brightness(0) invert(1)" }}
17
- draggable={false}
18
- />
19
- );
20
- }
21
-
22
- const IconCaptions = <TimelineIcon src={`${ICON_BASE}/captions.svg`} />;
23
- const IconImage = <TimelineIcon src={`${ICON_BASE}/image.svg`} />;
24
- const IconMusic = <TimelineIcon src={`${ICON_BASE}/music.svg`} />;
25
- const IconText = <TimelineIcon src={`${ICON_BASE}/text.svg`} />;
26
- const IconComposition = <TimelineIcon src={`${ICON_BASE}/composition.svg`} />;
27
- const IconAudio = <TimelineIcon src={`${ICON_BASE}/audio.svg`} />;
28
-
29
- const ICONS: Record<string, ReactNode> = {
30
- video: IconImage,
31
- audio: IconMusic,
32
- img: IconImage,
33
- div: IconComposition,
34
- span: IconCaptions,
35
- p: IconText,
36
- h1: IconText,
37
- section: IconComposition,
38
- sfx: IconAudio,
39
- };
3
+ export type TrackVisualStyle = TimelineTrackStyle;
40
4
 
41
5
  export function getTrackStyle(tag: string): TrackVisualStyle {
42
6
  // Defensive: callers may pass an empty/undefined tag; fall back to "div"
43
7
  // (restores the #1679 null-guard that a restack had dropped).
44
8
  const safeTag = tag || "div";
45
- const trackStyle = getTimelineTrackStyle(safeTag);
46
- const normalized = safeTag.toLowerCase();
47
- const icon =
48
- normalized.startsWith("h") && normalized.length === 2 && "123456".includes(normalized[1] ?? "")
49
- ? ICONS.h1
50
- : (ICONS[normalized] ?? IconComposition);
51
- return { ...trackStyle, icon };
9
+ return getTimelineTrackStyle(safeTag);
52
10
  }
@@ -11,9 +11,9 @@ const TIMELINE_SCROLL_BUFFER = 20;
11
11
 
12
12
  /* ── Tick generation ──────────────────────────────────────────────── */
13
13
  function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
14
- const zoomIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
14
+ const zoomIntervals = [0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
15
15
  if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
16
- const targetMajorPx = 128;
16
+ const targetMajorPx = 88;
17
17
  return (
18
18
  zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600
19
19
  );
@@ -24,20 +24,14 @@ function getMajorTickInterval(duration: number, pixelsPerSecond?: number): numbe
24
24
  }
25
25
 
26
26
  function getMinorTickInterval(majorInterval: number, pixelsPerSecond?: number): number {
27
- let interval = majorInterval / 2;
28
- if (majorInterval >= 30) interval = majorInterval / 6;
29
- else if (majorInterval >= 15) interval = majorInterval / 3;
30
- else if (majorInterval >= 5) interval = majorInterval / 5;
31
- else if (majorInterval >= 1) interval = majorInterval / 4;
32
-
33
27
  if (
34
28
  Number.isFinite(pixelsPerSecond) &&
35
29
  (pixelsPerSecond ?? 0) > 0 &&
36
- interval * (pixelsPerSecond ?? 0) < 20
30
+ (majorInterval / 2) * (pixelsPerSecond ?? 0) < 12
37
31
  ) {
38
- return Math.max(0.25, majorInterval / 2);
32
+ return 0;
39
33
  }
40
- return Math.max(0.25, interval);
34
+ return majorInterval / 2;
41
35
  }
42
36
 
43
37
  export function generateTicks(
@@ -51,17 +45,13 @@ export function generateTicks(
51
45
  const major: number[] = [];
52
46
  const minor: number[] = [];
53
47
  const maxTicks = 2000; // Safety cap to prevent runaway tick generation
54
- for (
55
- let t = 0;
56
- t <= duration + 0.001 && major.length + minor.length < maxTicks;
57
- t += minorInterval
58
- ) {
48
+ for (let t = 0; t <= duration + 0.001 && major.length < maxTicks; t += majorInterval) {
59
49
  const rounded = Math.round(t * 100) / 100;
60
- const isMajor =
61
- Math.abs(rounded % majorInterval) < 0.01 ||
62
- Math.abs((rounded % majorInterval) - majorInterval) < 0.01;
63
- if (isMajor) major.push(rounded);
64
- else minor.push(rounded);
50
+ major.push(rounded);
51
+ if (minorInterval > 0 && major.length + minor.length < maxTicks) {
52
+ const midpoint = Math.round((t + minorInterval) * 100) / 100;
53
+ if (midpoint <= duration + 0.001) minor.push(midpoint);
54
+ }
65
55
  }
66
56
  return { major, minor };
67
57
  }
@@ -69,6 +59,12 @@ export function generateTicks(
69
59
  export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
70
60
  if (!Number.isFinite(time)) return "0:00";
71
61
  const safeTime = Math.max(0, time);
62
+ if (majorInterval < 0.1) {
63
+ const totalHundredths = Math.round(safeTime * 100);
64
+ const wholeSeconds = Math.floor(totalHundredths / 100);
65
+ const hundredth = totalHundredths % 100;
66
+ return `${formatTime(wholeSeconds)}.${hundredth.toString().padStart(2, "0")}`;
67
+ }
72
68
  if (majorInterval < 1) {
73
69
  const totalTenths = Math.round(safeTime * 10);
74
70
  const wholeSeconds = Math.floor(totalTenths / 10);
@@ -101,7 +97,7 @@ export function getTimelineScrollLeftForZoomTransition(
101
97
  nextZoomMode: ZoomMode,
102
98
  currentScrollLeft: number,
103
99
  ): number {
104
- if (previousZoomMode === "manual" && nextZoomMode === "fit") return 0;
100
+ if (nextZoomMode === "fit") return 0;
105
101
  return currentScrollLeft;
106
102
  }
107
103