@hyperframes/studio 0.6.96 → 0.6.98

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 (120) hide show
  1. package/dist/assets/hyperframes-player-DgsMQSvV.js +418 -0
  2. package/dist/assets/index-B62bDCQv.css +1 -0
  3. package/dist/assets/index-Ce3pBm_I.js +252 -0
  4. package/dist/assets/{index-BWFaypdT.js → index-D-ET9M0b.js} +1 -1
  5. package/dist/assets/index-D-bS9Dxx.js +1 -0
  6. package/dist/index.html +2 -2
  7. package/package.json +7 -5
  8. package/src/App.tsx +182 -177
  9. package/src/captions/store.ts +11 -11
  10. package/src/components/StudioHeader.tsx +4 -4
  11. package/src/components/StudioLeftSidebar.tsx +2 -2
  12. package/src/components/StudioPreviewArea.tsx +225 -183
  13. package/src/components/StudioRightPanel.tsx +3 -3
  14. package/src/components/TimelineToolbar.tsx +25 -0
  15. package/src/components/editor/DomEditOverlay.tsx +2 -5
  16. package/src/components/editor/EaseCurveSection.tsx +2 -3
  17. package/src/components/editor/GestureTrailOverlay.tsx +4 -3
  18. package/src/components/editor/LayersPanel.tsx +3 -9
  19. package/src/components/editor/PropertyPanel.tsx +20 -61
  20. package/src/components/editor/colorValue.ts +3 -1
  21. package/src/components/editor/domEditOverlayGestures.ts +54 -1
  22. package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
  23. package/src/components/editor/gradientValue.ts +3 -3
  24. package/src/components/editor/keyframeMove.test.ts +101 -0
  25. package/src/components/editor/keyframeMove.ts +151 -0
  26. package/src/components/editor/manualEditsDom.ts +0 -12
  27. package/src/components/editor/propertyPanelHelpers.ts +10 -38
  28. package/src/components/editor/propertyPanelMediaSection.tsx +1 -5
  29. package/src/components/editor/propertyPanelTimingSection.tsx +1 -6
  30. package/src/components/editor/propertyPanelTransformCommit.ts +129 -0
  31. package/src/components/editor/studioMotionOps.test.ts +1 -1
  32. package/src/components/editor/studioMotionOps.ts +2 -1
  33. package/src/components/editor/useDomEditOverlayGestures.ts +1 -46
  34. package/src/components/nle/NLELayout.tsx +1 -24
  35. package/src/components/sidebar/BlocksTab.tsx +2 -2
  36. package/src/contexts/DomEditContext.tsx +134 -31
  37. package/src/contexts/StudioContext.tsx +90 -40
  38. package/src/contexts/TimelineEditContext.tsx +47 -0
  39. package/src/hooks/domEditCommitTypes.ts +14 -0
  40. package/src/hooks/gsapDragCommit.ts +9 -24
  41. package/src/hooks/gsapKeyframeCacheHelpers.ts +2 -1
  42. package/src/hooks/gsapKeyframeCommit.ts +5 -15
  43. package/src/hooks/gsapRuntimeBridge.ts +18 -52
  44. package/src/hooks/gsapRuntimeKeyframes.ts +8 -57
  45. package/src/hooks/gsapRuntimeReaders.ts +19 -26
  46. package/src/hooks/gsapScriptCommitHelpers.ts +1 -11
  47. package/src/hooks/gsapScriptCommitTypes.ts +58 -0
  48. package/src/hooks/gsapShared.ts +157 -0
  49. package/src/hooks/timelineEditingHelpers.ts +63 -2
  50. package/src/hooks/useAnimatedPropertyCommit.ts +3 -25
  51. package/src/hooks/useAppHotkeys.ts +299 -377
  52. package/src/hooks/useConsoleErrorCapture.ts +33 -5
  53. package/src/hooks/useDomEditCommits.ts +35 -293
  54. package/src/hooks/useDomEditPositionPatchCommit.ts +1 -1
  55. package/src/hooks/useDomEditSession.ts +78 -249
  56. package/src/hooks/useDomEditTextCommits.ts +1 -1
  57. package/src/hooks/useDomEditWiring.ts +255 -0
  58. package/src/hooks/useDomGeometryCommits.ts +181 -0
  59. package/src/hooks/useDomSelection.ts +10 -27
  60. package/src/hooks/useEditorSave.ts +82 -0
  61. package/src/hooks/useElementLifecycleOps.ts +177 -0
  62. package/src/hooks/useEnableKeyframes.ts +10 -15
  63. package/src/hooks/useFileManager.ts +32 -114
  64. package/src/hooks/useFileTree.ts +80 -0
  65. package/src/hooks/useGestureCommit.ts +7 -5
  66. package/src/hooks/useGestureRecording.ts +1 -1
  67. package/src/hooks/useGsapAnimationOps.ts +122 -0
  68. package/src/hooks/useGsapArcPathOps.ts +61 -0
  69. package/src/hooks/useGsapAwareEditing.ts +242 -0
  70. package/src/hooks/useGsapKeyframeOps.ts +167 -0
  71. package/src/hooks/useGsapPropertyDebounce.ts +135 -0
  72. package/src/hooks/useGsapScriptCommits.ts +58 -570
  73. package/src/hooks/useGsapSelectionHandlers.ts +22 -9
  74. package/src/hooks/useGsapTweenCache.ts +35 -29
  75. package/src/hooks/useLintModal.ts +7 -0
  76. package/src/hooks/useMusicBeatAnalysis.ts +152 -0
  77. package/src/hooks/useRazorSplit.ts +1 -1
  78. package/src/hooks/useRenderClipContent.ts +46 -21
  79. package/src/hooks/useTimelineEditing.ts +48 -4
  80. package/src/player/components/AudioWaveform.tsx +29 -4
  81. package/src/player/components/BeatStrip.tsx +166 -0
  82. package/src/player/components/Timeline.tsx +39 -18
  83. package/src/player/components/TimelineCanvas.tsx +52 -12
  84. package/src/player/components/TimelineClipDiamonds.tsx +130 -20
  85. package/src/player/components/TimelinePropertyRows.tsx +8 -2
  86. package/src/player/components/TimelineRuler.tsx +36 -2
  87. package/src/player/components/timelineEditing.ts +30 -5
  88. package/src/player/components/useTimelineClipDrag.ts +155 -4
  89. package/src/player/components/useTimelinePlayhead.ts +30 -1
  90. package/src/player/hooks/useTimelinePlayer.ts +47 -45
  91. package/src/player/lib/mediaProbe.ts +46 -3
  92. package/src/player/lib/playbackScrub.ts +16 -0
  93. package/src/player/lib/timelineDOM.ts +10 -2
  94. package/src/player/lib/timelineIframeHelpers.ts +89 -0
  95. package/src/player/store/playerStore.ts +92 -33
  96. package/src/utils/beatEditActions.ts +109 -0
  97. package/src/utils/beatEditing.ts +136 -0
  98. package/src/utils/clipboardPayload.ts +3 -2
  99. package/src/utils/compositionPatterns.ts +2 -0
  100. package/src/utils/keyframeSelection.test.ts +45 -0
  101. package/src/utils/keyframeSelection.ts +29 -0
  102. package/src/utils/rounding.ts +9 -0
  103. package/src/utils/studioHelpers.ts +5 -2
  104. package/src/utils/studioUrlState.ts +2 -1
  105. package/src/utils/timelineAssetDrop.ts +6 -5
  106. package/src/utils/timelineInspector.ts +15 -100
  107. package/dist/assets/hyperframes-player-0esDKGRk.js +0 -418
  108. package/dist/assets/index-B0twsRu0.css +0 -1
  109. package/dist/assets/index-BA979yF1.js +0 -251
  110. package/src/components/editor/DopesheetStrip.tsx +0 -141
  111. package/src/components/editor/StaggerControls.tsx +0 -61
  112. package/src/components/editor/TimelineLayerPanel.test.ts +0 -42
  113. package/src/components/editor/TimelineLayerPanel.tsx +0 -15
  114. package/src/components/nle/TimelineEditorNotice.tsx +0 -133
  115. package/src/hooks/gsapRuntimePreview.ts +0 -19
  116. package/src/player/components/timelineUtils.ts +0 -211
  117. package/src/utils/audioBeatDetection.ts +0 -58
  118. package/src/utils/keyframeSnapping.test.ts +0 -74
  119. package/src/utils/keyframeSnapping.ts +0 -63
  120. package/src/utils/timelineInspector.test.ts +0 -79
@@ -1,141 +0,0 @@
1
- import { memo, useCallback, useRef } from "react";
2
-
3
- interface DopesheetKeyframe {
4
- percentage: number;
5
- properties: Record<string, number | string>;
6
- ease?: string;
7
- }
8
-
9
- interface DopesheetStripProps {
10
- keyframes: DopesheetKeyframe[];
11
- selectedPercentage: number | null;
12
- currentPercentage: number;
13
- accentColor?: string;
14
- onSelectKeyframe: (percentage: number) => void;
15
- onDragKeyframe?: (fromPct: number, toPct: number) => void;
16
- }
17
-
18
- const DIAMOND_SIZE = 8;
19
- const HALF = DIAMOND_SIZE / 2;
20
- const STRIP_HEIGHT = 20;
21
- const PADDING_X = 8;
22
-
23
- export const DopesheetStrip = memo(function DopesheetStrip({
24
- keyframes,
25
- selectedPercentage,
26
- currentPercentage,
27
- accentColor = "#3CE6AC",
28
- onSelectKeyframe,
29
- onDragKeyframe,
30
- }: DopesheetStripProps) {
31
- const containerRef = useRef<HTMLDivElement>(null);
32
- const dragRef = useRef<{ startX: number; startPct: number } | null>(null);
33
-
34
- const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage);
35
-
36
- const handlePointerDown = useCallback(
37
- (e: React.PointerEvent, pct: number) => {
38
- if (e.button !== 0) return;
39
- e.stopPropagation();
40
- const startX = e.clientX;
41
-
42
- const handleMove = (me: PointerEvent) => {
43
- if (Math.abs(me.clientX - startX) > 4) {
44
- dragRef.current = { startX, startPct: pct };
45
- }
46
- };
47
-
48
- const handleUp = (ue: PointerEvent) => {
49
- document.removeEventListener("pointermove", handleMove);
50
- document.removeEventListener("pointerup", handleUp);
51
- if (dragRef.current && containerRef.current && onDragKeyframe) {
52
- const rect = containerRef.current.getBoundingClientRect();
53
- const usableWidth = rect.width - PADDING_X * 2;
54
- const dx = ue.clientX - dragRef.current.startX;
55
- const dpct = (dx / usableWidth) * 100;
56
- const newPct = Math.max(0, Math.min(100, Math.round((pct + dpct) * 10) / 10));
57
- if (newPct !== pct) onDragKeyframe(pct, newPct);
58
- } else {
59
- onSelectKeyframe(pct);
60
- }
61
- dragRef.current = null;
62
- };
63
-
64
- document.addEventListener("pointermove", handleMove);
65
- document.addEventListener("pointerup", handleUp);
66
- },
67
- [onSelectKeyframe, onDragKeyframe],
68
- );
69
-
70
- return (
71
- <div
72
- ref={containerRef}
73
- className="relative w-full rounded-md bg-neutral-900/60 border border-neutral-800/50"
74
- style={{ height: STRIP_HEIGHT }}
75
- >
76
- {/* Playhead indicator */}
77
- <div
78
- className="absolute top-0 bottom-0 w-px bg-white/30"
79
- style={{
80
- left: `${PADDING_X + (currentPercentage / 100) * (100 - PADDING_X * 2)}%`,
81
- marginLeft: -0.5,
82
- }}
83
- />
84
-
85
- {/* Diamond markers */}
86
- <svg
87
- className="absolute inset-0 w-full"
88
- style={{ height: STRIP_HEIGHT }}
89
- viewBox={`0 0 100 ${STRIP_HEIGHT}`}
90
- preserveAspectRatio="none"
91
- >
92
- {sorted.map((kf) => {
93
- const x = PADDING_X + (kf.percentage / 100) * (100 - PADDING_X * 2);
94
- const y = STRIP_HEIGHT / 2;
95
- const isSelected =
96
- selectedPercentage !== null && Math.abs(kf.percentage - selectedPercentage) < 0.5;
97
- const isHold = kf.ease === "steps(1)";
98
- const fillColor = isSelected ? accentColor : "#737373";
99
-
100
- return (
101
- <g
102
- key={kf.percentage}
103
- onPointerDown={(e) => handlePointerDown(e, kf.percentage)}
104
- style={{ cursor: "pointer" }}
105
- >
106
- {isHold ? (
107
- <rect
108
- x={x - HALF}
109
- y={y - HALF}
110
- width={DIAMOND_SIZE}
111
- height={DIAMOND_SIZE}
112
- fill={fillColor}
113
- />
114
- ) : (
115
- <rect
116
- x={x - HALF}
117
- y={y - HALF}
118
- width={DIAMOND_SIZE}
119
- height={DIAMOND_SIZE}
120
- fill={fillColor}
121
- transform={`rotate(45, ${x}, ${y})`}
122
- />
123
- )}
124
- </g>
125
- );
126
- })}
127
- </svg>
128
-
129
- {/* Time labels */}
130
- {sorted.length > 0 && (
131
- <div
132
- className="absolute bottom-0 left-0 right-0 flex justify-between px-2 text-[8px] text-neutral-600 pointer-events-none"
133
- style={{ lineHeight: "10px" }}
134
- >
135
- <span>{sorted[0].percentage}%</span>
136
- {sorted.length > 1 && <span>{sorted[sorted.length - 1].percentage}%</span>}
137
- </div>
138
- )}
139
- </div>
140
- );
141
- });
@@ -1,61 +0,0 @@
1
- import { memo, useState } from "react";
2
- import { MetricField } from "./propertyPanelPrimitives";
3
-
4
- export type StaggerOrder = "dom" | "reverse" | "center" | "edges" | "random";
5
-
6
- interface StaggerControlsProps {
7
- elementCount: number;
8
- onApplyStagger: (offsetMs: number, order: StaggerOrder) => void;
9
- }
10
-
11
- const ORDER_OPTIONS: StaggerOrder[] = ["dom", "reverse", "center", "edges", "random"];
12
- const ORDER_LABELS: Record<StaggerOrder, string> = {
13
- dom: "DOM order",
14
- reverse: "Reverse",
15
- center: "Center out",
16
- edges: "Edges in",
17
- random: "Random",
18
- };
19
-
20
- export const StaggerControls = memo(function StaggerControls({
21
- elementCount,
22
- onApplyStagger,
23
- }: StaggerControlsProps) {
24
- const [offsetMs, setOffsetMs] = useState(80);
25
- const [order, setOrder] = useState<StaggerOrder>("dom");
26
-
27
- if (elementCount < 2) return null;
28
-
29
- return (
30
- <div className="flex items-center gap-2 rounded-lg border border-neutral-800 bg-neutral-900/50 px-2 py-1.5">
31
- <span className="text-[10px] font-medium text-neutral-500">Stagger</span>
32
- <MetricField
33
- label="Offset"
34
- value={String(offsetMs)}
35
- suffix="ms"
36
- onCommit={(raw) => {
37
- const v = Number.parseInt(raw, 10);
38
- if (Number.isFinite(v) && v >= 0) setOffsetMs(v);
39
- }}
40
- />
41
- <select
42
- value={order}
43
- onChange={(e) => setOrder(e.target.value as StaggerOrder)}
44
- className="rounded-md border border-neutral-700 bg-neutral-900 px-1.5 py-1 text-[10px] text-neutral-200 outline-none"
45
- >
46
- {ORDER_OPTIONS.map((o) => (
47
- <option key={o} value={o}>
48
- {ORDER_LABELS[o]}
49
- </option>
50
- ))}
51
- </select>
52
- <button
53
- type="button"
54
- onClick={() => onApplyStagger(offsetMs, order)}
55
- className="rounded-md bg-panel-accent/10 px-2 py-1 text-[10px] font-semibold text-panel-accent transition-colors hover:bg-panel-accent/20"
56
- >
57
- Apply ({elementCount})
58
- </button>
59
- </div>
60
- );
61
- });
@@ -1,42 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { Window } from "happy-dom";
3
- import type { DomEditLayerItem } from "./domEditing";
4
- import { getTimelineLayerPanelSummary } from "./TimelineLayerPanel";
5
-
6
- function createLayer(overrides: Partial<DomEditLayerItem> = {}): DomEditLayerItem {
7
- const window = new Window();
8
- return {
9
- childCount: 0,
10
- depth: 0,
11
- element: window.document.createElement(overrides.tagName ?? "div"),
12
- key: "layer",
13
- label: "Layer",
14
- sourceFile: "index.html",
15
- tagName: "div",
16
- ...overrides,
17
- };
18
- }
19
-
20
- describe("TimelineLayerPanel", () => {
21
- it("describes a leaf media clip as a single selectable layer", () => {
22
- expect(
23
- getTimelineLayerPanelSummary([
24
- createLayer({ key: "alpha-video", label: "Alpha Video", tagName: "video" }),
25
- ]),
26
- ).toBe("Single selectable media layer");
27
- });
28
-
29
- it("describes real nested layers with the nested count", () => {
30
- expect(
31
- getTimelineLayerPanelSummary([
32
- createLayer({ key: "root", childCount: 2 }),
33
- createLayer({ key: "title", depth: 1 }),
34
- createLayer({ key: "subtitle", depth: 1 }),
35
- ]),
36
- ).toBe("2 nested selectable layers");
37
- });
38
-
39
- it("keeps empty layer lists explicit", () => {
40
- expect(getTimelineLayerPanelSummary([])).toBe("No selectable layers");
41
- });
42
- });
@@ -1,15 +0,0 @@
1
- import type { DomEditLayerItem } from "./domEditing";
2
-
3
- const MEDIA_LAYER_TAGS = new Set(["audio", "canvas", "img", "picture", "svg", "video"]);
4
-
5
- export function getTimelineLayerPanelSummary(layers: readonly DomEditLayerItem[]): string {
6
- const childCount = Math.max(0, layers.length - 1);
7
- if (childCount > 0) {
8
- return `${childCount} nested selectable layer${childCount === 1 ? "" : "s"}`;
9
- }
10
- const layer = layers[0];
11
- if (!layer) return "No selectable layers";
12
- return MEDIA_LAYER_TAGS.has(layer.tagName.trim().toLowerCase())
13
- ? "Single selectable media layer"
14
- : "Single selectable layer";
15
- }
@@ -1,133 +0,0 @@
1
- import { TIMELINE_TOGGLE_SHORTCUT_LABEL } from "../../utils/timelineDiscovery";
2
- import { PlayheadIndicator } from "../../player/components/PlayheadIndicator";
3
-
4
- interface TimelineEditorNoticeProps {
5
- onDismiss: () => void;
6
- }
7
-
8
- export function TimelineEditorNotice({ onDismiss }: TimelineEditorNoticeProps) {
9
- return (
10
- <aside
11
- aria-live="polite"
12
- className="pointer-events-none relative w-[320px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-white/10 bg-[#0f1115]/88 text-neutral-100 shadow-[0_18px_40px_rgba(0,0,0,0.3),0_4px_14px_rgba(0,0,0,0.18)] backdrop-blur-xl"
13
- >
14
- <style>{`
15
- @keyframes hfTimelineNoticeClipNudge {
16
- 0%, 100% { transform: translate3d(0, 0, 0); }
17
- 20% { transform: translate3d(0, 0, 0); }
18
- 52% { transform: translate3d(12px, 0, 0); }
19
- 72% { transform: translate3d(12px, 0, 0); }
20
- 100% { transform: translate3d(0, 0, 0); }
21
- }
22
-
23
- @keyframes hfTimelineNoticePlayheadSweep {
24
- 0% { transform: translateX(0); opacity: 0; }
25
- 10% { opacity: 1; }
26
- 75% { opacity: 1; }
27
- 100% { transform: translateX(218px); opacity: 0; }
28
- }
29
-
30
- @media (prefers-reduced-motion: reduce) {
31
- .hf-timeline-notice-clip,
32
- .hf-timeline-notice-playhead {
33
- animation: none !important;
34
- }
35
- }
36
- `}</style>
37
-
38
- <button
39
- type="button"
40
- onClick={onDismiss}
41
- aria-label="Dismiss timeline editor notice"
42
- className="pointer-events-auto absolute right-3 top-3 z-10 flex h-7 w-7 items-center justify-center rounded-lg text-neutral-500 transition-colors duration-150 hover:bg-white/[0.06] hover:text-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-studio-accent/50"
43
- >
44
- <svg
45
- width="11"
46
- height="11"
47
- viewBox="0 0 24 24"
48
- fill="none"
49
- stroke="currentColor"
50
- strokeWidth="2.25"
51
- strokeLinecap="round"
52
- aria-hidden="true"
53
- >
54
- <line x1="18" y1="6" x2="6" y2="18" />
55
- <line x1="6" y1="6" x2="18" y2="18" />
56
- </svg>
57
- </button>
58
-
59
- <div className="flex items-start gap-3 px-4 py-3.5">
60
- <div className="min-w-0 flex-1">
61
- <div
62
- aria-hidden="true"
63
- className="mb-3 overflow-hidden rounded-[14px] bg-[#0d1117] p-2.5"
64
- >
65
- <div className="relative overflow-hidden rounded-[11px] bg-[#0f141c] px-2.5 pb-2 pt-1.5">
66
- <div className="mb-1.5 flex items-center justify-between pl-6 pr-1 text-[8px] font-medium text-[#7f8796]">
67
- <span>0:00</span>
68
- <span>0:05</span>
69
- <span>0:10</span>
70
- </div>
71
-
72
- <div className="pointer-events-none absolute inset-x-0 top-[18px] h-px bg-white/[0.04]" />
73
- <div
74
- className="hf-timeline-notice-playhead pointer-events-none absolute left-[31px] top-[18px] h-[70px] w-0"
75
- style={{
76
- animation:
77
- "hfTimelineNoticePlayheadSweep 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite",
78
- }}
79
- >
80
- <PlayheadIndicator />
81
- </div>
82
-
83
- <div className="flex flex-col gap-1.5">
84
- {[0, 1, 2].map((trackIndex) => (
85
- <div
86
- key={trackIndex}
87
- className="relative h-6 overflow-hidden rounded-[10px] bg-white/[0.035]"
88
- >
89
- <div className="absolute inset-y-0 left-[24px] w-px bg-white/[0.035]" />
90
- <div className="absolute inset-y-0 left-[100px] w-px bg-white/[0.035]" />
91
- <div className="absolute inset-y-0 left-[176px] w-px bg-white/[0.035]" />
92
- </div>
93
- ))}
94
- </div>
95
-
96
- <div className="pointer-events-none absolute inset-x-0 top-[21px] h-[70px]">
97
- <div className="absolute left-[34px] top-[3px] h-[18px] w-[56px] rounded-[9px] bg-white/[0.07]" />
98
- <div
99
- className="hf-timeline-notice-clip absolute left-[82px] top-[27px] h-[18px] w-[110px] rounded-[9px] bg-studio-accent/18 ring-1 ring-inset ring-studio-accent/28"
100
- style={{
101
- animation:
102
- "hfTimelineNoticeClipNudge 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite",
103
- }}
104
- />
105
- <div className="absolute left-[52px] top-[51px] h-[18px] w-[72px] rounded-[9px] bg-white/[0.07]" />
106
- </div>
107
- </div>
108
- </div>
109
-
110
- <div className="min-w-0 pr-9">
111
- <p className="text-[12px] font-semibold leading-none tracking-tight text-neutral-100">
112
- Timeline editing is on
113
- </p>
114
- <p className="mt-1.5 text-[12px] leading-5 text-neutral-300">
115
- Drag clips to move timing, use{" "}
116
- <span className="font-mono text-[11px] text-studio-accent">Shift</span> + click to
117
- edit a full clip range, and watch for resize handles only on clips Studio can patch
118
- safely. Toggle the timeline with{" "}
119
- <span className="rounded-md border border-white/8 bg-white/[0.04] px-1.5 py-0.5 font-mono text-[11px] text-studio-accent">
120
- {TIMELINE_TOGGLE_SHORTCUT_LABEL}
121
- </span>
122
- .
123
- </p>
124
- </div>
125
-
126
- <div className="mt-2 text-[10px] leading-none text-neutral-500">
127
- Dismiss once and it stays hidden.
128
- </div>
129
- </div>
130
- </div>
131
- </aside>
132
- );
133
- }
@@ -1,19 +0,0 @@
1
- export function previewKeyframeChange(
2
- iframe: HTMLIFrameElement | null,
3
- selector: string,
4
- properties: Record<string, number | string>,
5
- ): boolean {
6
- if (!iframe?.contentWindow) return false;
7
- try {
8
- const gsap = (
9
- iframe.contentWindow as unknown as {
10
- gsap?: { set: (target: string, vars: Record<string, number | string>) => void };
11
- }
12
- ).gsap;
13
- if (!gsap?.set) return false;
14
- gsap.set(selector, properties);
15
- return true;
16
- } catch {
17
- return false;
18
- }
19
- }
@@ -1,211 +0,0 @@
1
- import { formatTime } from "../lib/time";
2
- import type { ZoomMode } from "../store/playerStore";
3
-
4
- /* ── Layout constants ─────────────────────────────────────────────── */
5
- export const GUTTER = 32;
6
- export const TRACK_H = 72;
7
- export const RULER_H = 24;
8
- export const CLIP_Y = 3;
9
- export const CLIP_HANDLE_W = 18;
10
- export const TIMELINE_SCROLL_BUFFER = 20;
11
-
12
- /* ── Tick Generation ────────────────────────────────────────────────── */
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];
15
- if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
16
- const targetMajorPx = 128;
17
- return (
18
- zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600
19
- );
20
- }
21
- const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
22
- const target = duration / 6;
23
- return durationIntervals.find((interval) => interval >= target) ?? 60;
24
- }
25
-
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
- if (
34
- Number.isFinite(pixelsPerSecond) &&
35
- (pixelsPerSecond ?? 0) > 0 &&
36
- interval * (pixelsPerSecond ?? 0) < 20
37
- ) {
38
- return Math.max(0.25, majorInterval / 2);
39
- }
40
- return Math.max(0.25, interval);
41
- }
42
-
43
- export function generateTicks(
44
- duration: number,
45
- pixelsPerSecond?: number,
46
- ): { major: number[]; minor: number[] } {
47
- if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
48
- return { major: [], minor: [] };
49
- const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
50
- const minorInterval = getMinorTickInterval(majorInterval, pixelsPerSecond);
51
- const major: number[] = [];
52
- const minor: number[] = [];
53
- const maxTicks = 2000;
54
- for (
55
- let t = 0;
56
- t <= duration + 0.001 && major.length + minor.length < maxTicks;
57
- t += minorInterval
58
- ) {
59
- 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);
65
- }
66
- return { major, minor };
67
- }
68
-
69
- export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
70
- if (!Number.isFinite(time)) return "0:00";
71
- const safeTime = Math.max(0, time);
72
- if (majorInterval < 1) {
73
- const totalTenths = Math.round(safeTime * 10);
74
- const wholeSeconds = Math.floor(totalTenths / 10);
75
- const tenth = totalTenths % 10;
76
- return `${formatTime(wholeSeconds)}.${tenth}`;
77
- }
78
- if (duration >= 3600 || safeTime >= 3600) {
79
- const totalSeconds = Math.floor(safeTime);
80
- const hours = Math.floor(totalSeconds / 3600);
81
- const minutes = Math.floor((totalSeconds % 3600) / 60);
82
- const seconds = totalSeconds % 60;
83
- return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
84
- }
85
- return formatTime(safeTime);
86
- }
87
-
88
- export function shouldAutoScrollTimeline(
89
- zoomMode: ZoomMode,
90
- scrollWidth: number,
91
- clientWidth: number,
92
- ): boolean {
93
- if (zoomMode === "fit") return false;
94
- if (!Number.isFinite(scrollWidth) || !Number.isFinite(clientWidth)) return false;
95
- return scrollWidth - clientWidth > 1;
96
- }
97
-
98
- export function getTimelineScrollLeftForZoomTransition(
99
- previousZoomMode: ZoomMode | null,
100
- nextZoomMode: ZoomMode,
101
- currentScrollLeft: number,
102
- ): number {
103
- if (previousZoomMode === "manual" && nextZoomMode === "fit") return 0;
104
- return currentScrollLeft;
105
- }
106
-
107
- export function getTimelineScrollLeftForZoomAnchor(input: {
108
- pointerX: number;
109
- currentScrollLeft: number;
110
- gutter: number;
111
- currentPixelsPerSecond: number;
112
- nextPixelsPerSecond: number;
113
- duration: number;
114
- }): number {
115
- const currentPps = Math.max(0, input.currentPixelsPerSecond);
116
- const nextPps = Math.max(0, input.nextPixelsPerSecond);
117
- if (
118
- !Number.isFinite(input.pointerX) ||
119
- !Number.isFinite(input.currentScrollLeft) ||
120
- !Number.isFinite(input.duration) ||
121
- input.duration <= 0 ||
122
- currentPps <= 0 ||
123
- nextPps <= 0
124
- ) {
125
- return Math.max(0, input.currentScrollLeft);
126
- }
127
- const timelineX = Math.max(0, input.currentScrollLeft + input.pointerX - input.gutter);
128
- const timeAtPointer = Math.max(0, Math.min(input.duration, timelineX / currentPps));
129
- return Math.max(0, input.gutter + timeAtPointer * nextPps - input.pointerX);
130
- }
131
-
132
- export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
133
- if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
134
- return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
135
- }
136
-
137
- export function getTimelineCanvasHeight(trackCount: number): number {
138
- return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
139
- }
140
-
141
- export function shouldShowTimelineShortcutHint(
142
- scrollHeight: number,
143
- clientHeight: number,
144
- ): boolean {
145
- if (!Number.isFinite(scrollHeight) || !Number.isFinite(clientHeight)) return true;
146
- return scrollHeight - clientHeight <= 1;
147
- }
148
-
149
- export function shouldHandleTimelineDeleteKey(input: {
150
- key: string;
151
- metaKey?: boolean;
152
- ctrlKey?: boolean;
153
- altKey?: boolean;
154
- target?: EventTarget | null;
155
- }): boolean {
156
- if (input.key !== "Delete" && input.key !== "Backspace") return false;
157
- if (input.metaKey || input.ctrlKey || input.altKey) return false;
158
- const target =
159
- input.target && typeof input.target === "object"
160
- ? (input.target as {
161
- tagName?: string;
162
- isContentEditable?: boolean;
163
- closest?: (selector: string) => Element | null;
164
- })
165
- : null;
166
- if (target) {
167
- const tag = target.tagName?.toLowerCase() ?? "";
168
- if (target.isContentEditable) return false;
169
- if (["input", "textarea", "select"].includes(tag)) return false;
170
- if (typeof target.closest === "function" && target.closest("[contenteditable='true']")) {
171
- return false;
172
- }
173
- }
174
- return true;
175
- }
176
-
177
- export function getDefaultDroppedTrack(trackOrder: number[], rowIndex?: number): number {
178
- if (trackOrder.length === 0) return 0;
179
- if (rowIndex == null || rowIndex < 0) return trackOrder[0];
180
- if (rowIndex >= trackOrder.length) {
181
- return Math.max(...trackOrder) + 1;
182
- }
183
- return trackOrder[rowIndex] ?? trackOrder[trackOrder.length - 1] ?? 0;
184
- }
185
-
186
- export function resolveTimelineAssetDrop(
187
- input: {
188
- rectLeft: number;
189
- rectTop: number;
190
- scrollLeft: number;
191
- scrollTop: number;
192
- pixelsPerSecond: number;
193
- duration: number;
194
- trackHeight: number;
195
- trackOrder: number[];
196
- },
197
- clientX: number,
198
- clientY: number,
199
- ): { start: number; track: number } {
200
- const x = clientX - input.rectLeft + input.scrollLeft - GUTTER;
201
- const y = clientY - input.rectTop + input.scrollTop - RULER_H;
202
- const start = Math.max(
203
- 0,
204
- Math.min(input.duration, Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100),
205
- );
206
- const rowIndex = Math.floor(y / Math.max(input.trackHeight, 1));
207
- return {
208
- start,
209
- track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
210
- };
211
- }
@@ -1,58 +0,0 @@
1
- const WINDOW_SIZE = 1024;
2
- const HOP_SIZE = 512;
3
-
4
- // fallow-ignore-next-line complexity
5
- export async function detectBeats(audioBuffer: AudioBuffer): Promise<number[]> {
6
- const channelData = audioBuffer.getChannelData(0);
7
- const sampleRate = audioBuffer.sampleRate;
8
-
9
- const energies: number[] = [];
10
- for (let i = 0; i < channelData.length - WINDOW_SIZE; i += HOP_SIZE) {
11
- let sum = 0;
12
- for (let j = 0; j < WINDOW_SIZE; j++) {
13
- const sample = channelData[i + j]!;
14
- sum += sample * sample;
15
- }
16
- energies.push(sum / WINDOW_SIZE);
17
- }
18
-
19
- const beats: number[] = [];
20
- const localWindowSize = 20;
21
-
22
- for (let i = localWindowSize; i < energies.length - localWindowSize; i++) {
23
- let localMean = 0;
24
- for (let j = i - localWindowSize; j < i + localWindowSize; j++) {
25
- localMean += energies[j]!;
26
- }
27
- localMean /= localWindowSize * 2;
28
-
29
- const threshold = localMean * 1.5;
30
- const current = energies[i]!;
31
-
32
- if (
33
- current > threshold &&
34
- current > (energies[i - 1] ?? 0) &&
35
- current > (energies[i + 1] ?? 0)
36
- ) {
37
- const timeInSeconds = (i * HOP_SIZE) / sampleRate;
38
- if (beats.length === 0 || timeInSeconds - beats[beats.length - 1]! > 0.1) {
39
- beats.push(Math.round(timeInSeconds * 1000) / 1000);
40
- }
41
- }
42
- }
43
-
44
- return beats;
45
- }
46
-
47
- // fallow-ignore-next-line complexity
48
- export async function detectBeatsFromUrl(url: string): Promise<number[]> {
49
- const audioContext = new AudioContext();
50
- try {
51
- const response = await fetch(url);
52
- const arrayBuffer = await response.arrayBuffer();
53
- const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
54
- return detectBeats(audioBuffer);
55
- } finally {
56
- await audioContext.close();
57
- }
58
- }