@hyperframes/studio 0.8.107 → 0.8.109

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 (183) hide show
  1. package/dist/assets/{hyperframes-player-h4mktSdm.js → hyperframes-player-CJYtikAy.js} +1 -1
  2. package/dist/assets/{index-lrT-gfHa.css → index-BQZBDqMs.css} +1 -1
  3. package/dist/assets/{index-zoqPmBF6.js → index-CMSgrT2F.js} +1 -1
  4. package/dist/assets/{index-CM1xA_jD.js → index-CYP6dMd7.js} +212 -210
  5. package/dist/assets/{index-D8GKc2D9.js → index-HTFVHUJ4.js} +1 -1
  6. package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
  7. package/dist/chunk-5VSXRGIX.js.map +1 -0
  8. package/dist/chunk-6LUNLTHR.js +611 -0
  9. package/dist/chunk-6LUNLTHR.js.map +1 -0
  10. package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
  11. package/dist/chunk-PQFEBX32.js.map +1 -0
  12. package/dist/{chunk-4OZC426H.js → chunk-QUXZVHOJ.js} +10 -504
  13. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  14. package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
  15. package/dist/index.d.ts +53 -4
  16. package/dist/index.html +2 -2
  17. package/dist/index.js +15746 -13173
  18. package/dist/index.js.map +1 -1
  19. package/dist/player.js +3 -3
  20. package/dist/styles.css +1 -1
  21. package/dist/ui.js +1 -1
  22. package/package.json +8 -8
  23. package/src/App.tsx +8 -11
  24. package/src/components/EditorShell.tsx +6 -0
  25. package/src/components/LinkedSelectionToggle.tsx +73 -0
  26. package/src/components/TimelineToolbar.test.tsx +31 -0
  27. package/src/components/TimelineToolbar.tsx +2 -0
  28. package/src/components/editor/CropPresetBar.tsx +69 -0
  29. package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
  30. package/src/components/editor/DomEditCropHandles.tsx +123 -95
  31. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  32. package/src/components/editor/InlineTextCaret.tsx +211 -0
  33. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  34. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  35. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  36. package/src/components/editor/carveOutput.ts +54 -0
  37. package/src/components/editor/cropPresetStore.ts +33 -0
  38. package/src/components/editor/cropPresets.test.ts +41 -0
  39. package/src/components/editor/cropPresets.ts +24 -0
  40. package/src/components/editor/domEditInlineText.ts +6 -4
  41. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  42. package/src/components/editor/domEditingLayers.test.ts +21 -0
  43. package/src/components/editor/domEditingLayers.ts +2 -0
  44. package/src/components/editor/dragUndoPaint.test.ts +71 -0
  45. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  46. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  47. package/src/components/editor/manualOffsetDrag.ts +11 -0
  48. package/src/components/editor/mediaAudioEdits.test.ts +79 -4
  49. package/src/components/editor/mediaAudioEdits.ts +66 -24
  50. package/src/components/editor/mediaLinkEdits.test.ts +321 -0
  51. package/src/components/editor/mediaLinkEdits.ts +282 -0
  52. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
  53. package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
  54. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +10 -5
  55. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  56. package/src/components/editor/useDomEditNudge.ts +17 -12
  57. package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
  58. package/src/components/editor/useFxCarve.ts +3 -61
  59. package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
  60. package/src/components/editor/useFxCarveGrouping.ts +8 -3
  61. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  62. package/src/components/editor/useVolumeAutomation.ts +29 -7
  63. package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
  64. package/src/components/nle/AudioMeterStrip.tsx +60 -2
  65. package/src/components/nle/useTimelineEditCallbacks.ts +12 -0
  66. package/src/components/ui/Menu.test.tsx +3 -22
  67. package/src/components/ui/Menu.tsx +2 -2
  68. package/src/components/ui/Popover.tsx +2 -2
  69. package/src/components/ui/Select.tsx +2 -2
  70. package/src/components/ui/Tooltip.tsx +2 -2
  71. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  72. package/src/components/ui/valueControls.test.tsx +2 -21
  73. package/src/contexts/TimelineEditContext.tsx +4 -0
  74. package/src/hooks/appHotkeysDispatch.ts +2 -1
  75. package/src/hooks/linkShortcuts.test.ts +70 -0
  76. package/src/hooks/linkShortcuts.ts +41 -0
  77. package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
  78. package/src/hooks/timelineLinkEditPlan.ts +123 -0
  79. package/src/hooks/timelineMoveAdapter.ts +13 -0
  80. package/src/hooks/timelineTimingSync.test.ts +59 -0
  81. package/src/hooks/timelineTimingSync.ts +22 -3
  82. package/src/hooks/useAppHotkeys.ts +9 -0
  83. package/src/hooks/useEditHistoryActions.paint.test.tsx +41 -0
  84. package/src/hooks/useEditHistoryActions.ts +13 -4
  85. package/src/hooks/useElementPicker.test.ts +62 -13
  86. package/src/hooks/useElementPicker.ts +10 -2
  87. package/src/hooks/useFreezeFrame.test.ts +56 -0
  88. package/src/hooks/useFreezeFrame.ts +121 -0
  89. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  90. package/src/hooks/useGsapScriptCommits.ts +23 -3
  91. package/src/hooks/usePersistentEditHistory.test.ts +9 -6
  92. package/src/hooks/usePersistentEditHistory.ts +1 -1
  93. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  94. package/src/hooks/usePreviewPersistence.ts +15 -0
  95. package/src/hooks/useRazorSplit.test.tsx +48 -0
  96. package/src/hooks/useRazorSplit.ts +26 -2
  97. package/src/hooks/useRenderClipContent.test.ts +108 -4
  98. package/src/hooks/useRenderClipContent.ts +33 -23
  99. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  100. package/src/hooks/useTimelineEditGuard.ts +54 -0
  101. package/src/hooks/useTimelineEditing.ts +38 -57
  102. package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
  103. package/src/hooks/useTimelineLinkEditing.ts +153 -0
  104. package/src/index.ts +2 -0
  105. package/src/player/components/AudibleVideoClipContent.tsx +29 -0
  106. package/src/player/components/AudioWaveform.test.tsx +30 -3
  107. package/src/player/components/AudioWaveform.tsx +6 -7
  108. package/src/player/components/ClipBadges.test.tsx +82 -0
  109. package/src/player/components/ClipBadges.tsx +67 -0
  110. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  111. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  112. package/src/player/components/ClipContextMenu.tsx +124 -77
  113. package/src/player/components/ClipPeakMarks.test.tsx +41 -0
  114. package/src/player/components/ClipPeakMarks.tsx +71 -0
  115. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  116. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  117. package/src/player/components/TimelineClip.test.tsx +17 -0
  118. package/src/player/components/TimelineClip.tsx +8 -0
  119. package/src/player/components/TimelineFxButton.test.tsx +2 -2
  120. package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
  121. package/src/player/components/TimelineTrackHeader.tsx +12 -25
  122. package/src/player/components/audioClipLink.test.ts +228 -31
  123. package/src/player/components/audioClipLink.ts +72 -25
  124. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
  125. package/src/player/components/clipMenuAudioItems.test.tsx +168 -0
  126. package/src/player/components/clipMenuAudioItems.tsx +116 -0
  127. package/src/player/components/clipMenuDuck.test.ts +117 -0
  128. package/src/player/components/clipMenuDuck.ts +147 -0
  129. package/src/player/components/clipMenuLinkItems.test.ts +90 -0
  130. package/src/player/components/clipMenuLinkItems.tsx +121 -0
  131. package/src/player/components/clipMenuNormalize.test.ts +95 -0
  132. package/src/player/components/clipMenuNormalize.ts +96 -0
  133. package/src/player/components/clipMenuToolItems.test.tsx +139 -0
  134. package/src/player/components/clipMenuToolItems.tsx +233 -0
  135. package/src/player/components/clipPeakRuns.test.ts +38 -0
  136. package/src/player/components/clipPeakRuns.ts +59 -0
  137. package/src/player/components/clipSync.test.ts +67 -0
  138. package/src/player/components/clipSync.ts +80 -0
  139. package/src/player/components/clipToolAttrs.test.ts +161 -0
  140. package/src/player/components/clipToolAttrs.ts +187 -0
  141. package/src/player/components/linkLabelColor.test.ts +20 -0
  142. package/src/player/components/linkLabelColor.ts +24 -0
  143. package/src/player/components/timelineCallbacks.ts +12 -0
  144. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  145. package/src/player/components/timelineClipDragTypes.ts +2 -0
  146. package/src/player/components/timelineClipGestureHandlers.ts +4 -1
  147. package/src/player/components/timelineLinkSelection.ts +23 -0
  148. package/src/player/components/useClipToolState.ts +92 -0
  149. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  150. package/src/player/components/useTimelineClipDrag.ts +21 -0
  151. package/src/player/components/useTimelineProviderState.tsx +3 -0
  152. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  153. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  154. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  155. package/src/player/lib/timelineDOM.test.ts +31 -0
  156. package/src/player/lib/timelineDOM.ts +13 -0
  157. package/src/player/lib/timelineElementHelpers.ts +9 -0
  158. package/src/player/store/timelineElement.ts +12 -0
  159. package/src/styles/components.css +27 -11
  160. package/src/styles/theme.css +0 -1
  161. package/src/timelineChromeExports.test.tsx +7 -0
  162. package/src/utils/audioMeterMath.test.ts +37 -0
  163. package/src/utils/audioMeterMath.ts +9 -3
  164. package/src/utils/exportPeakCeiling.ts +1 -0
  165. package/src/utils/gsapSoftReload.test.ts +238 -2
  166. package/src/utils/gsapSoftReload.ts +89 -74
  167. package/src/utils/gsapUndoRestore.test.ts +4 -3
  168. package/src/utils/gsapUndoRestore.ts +15 -0
  169. package/src/utils/linkedClipPreferences.ts +34 -0
  170. package/src/utils/previewRelativePath.ts +19 -0
  171. package/src/utils/softReloadTargets.ts +128 -0
  172. package/src/utils/sourcePatcher.ts +1 -1
  173. package/src/utils/studioPendingEdits.test.ts +66 -0
  174. package/src/utils/studioPendingEdits.ts +45 -11
  175. package/src/utils/studioUiPreferences.ts +8 -0
  176. package/src/utils/timelineInspector.test.ts +33 -2
  177. package/src/utils/timelineInspector.ts +16 -4
  178. package/dist/chunk-46XP7J3B.js.map +0 -1
  179. package/dist/chunk-4OZC426H.js.map +0 -1
  180. package/dist/chunk-DI4LCJHL.js +0 -102
  181. package/dist/chunk-DI4LCJHL.js.map +0 -1
  182. package/dist/chunk-NCUBAVGK.js.map +0 -1
  183. /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The attribute each clip-menu tool writes, and what a clip's attributes say is applied.
3
+ * Pure over strings so the menu, the badges and an agent's hand edits all agree.
4
+ */
5
+
6
+ import {
7
+ parseAudioFxChain,
8
+ serializeAudioFxChain,
9
+ type HfAudioFxChain,
10
+ } from "@hyperframes/core/audio-fx";
11
+ import { activeAudioFxPresetIds } from "@hyperframes/core/audio-fx-presets";
12
+ import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
13
+ import { parseRateLane } from "@hyperframes/core/speed-ramp";
14
+ import { applyPresetToChain } from "../../components/editor/useApplyAudioFxPreset";
15
+
16
+ export interface ClipToolChoice {
17
+ id: string;
18
+ label: string;
19
+ }
20
+
21
+ export const VOICE_CHOICES: readonly ClipToolChoice[] = [
22
+ { id: "voice-clean", label: "Clean" },
23
+ { id: "voice-broadcast", label: "Broadcast" },
24
+ { id: "voice-warm", label: "Warm" },
25
+ ];
26
+
27
+ export const CHARACTER_CHOICES: readonly ClipToolChoice[] = [
28
+ { id: "telephone", label: "Telephone" },
29
+ { id: "radio-am", label: "AM Radio" },
30
+ { id: "megaphone", label: "Megaphone" },
31
+ ];
32
+
33
+ export const LOOK_CHOICES: readonly ClipToolChoice[] = [
34
+ { id: "warm-daylight", label: "Warm daylight" },
35
+ { id: "clean-studio", label: "Clean studio" },
36
+ { id: "vintage-wash", label: "Vintage wash" },
37
+ { id: "mono-clean", label: "Mono" },
38
+ { id: "deep-contrast", label: "Deep contrast" },
39
+ { id: "home-movie-8mm", label: "Home movie" },
40
+ ];
41
+
42
+ const VOICE_MENU_IDS = new Set([...VOICE_CHOICES, ...CHARACTER_CHOICES].map((c) => c.id));
43
+
44
+ const EMPTY_CHAIN: HfAudioFxChain = { version: 1, nodes: [] };
45
+
46
+ function parseChainOrEmpty(raw: string | null | undefined): HfAudioFxChain {
47
+ if (!raw) return EMPTY_CHAIN;
48
+ try {
49
+ return parseAudioFxChain(raw);
50
+ } catch {
51
+ return EMPTY_CHAIN;
52
+ }
53
+ }
54
+
55
+ function labelFor(choices: readonly ClipToolChoice[], id: string | null): string | null {
56
+ return choices.find((choice) => choice.id === id)?.label ?? null;
57
+ }
58
+
59
+ export function activeVoicePreset(rawChain: string | null | undefined): string | null {
60
+ const ids = activeAudioFxPresetIds(parseChainOrEmpty(rawChain));
61
+ return ids.find((id) => VOICE_MENU_IDS.has(id)) ?? null;
62
+ }
63
+
64
+ function voicePresetLabel(id: string | null): string | null {
65
+ return labelFor(VOICE_CHOICES, id) ?? labelFor(CHARACTER_CHOICES, id);
66
+ }
67
+
68
+ /**
69
+ * The `data-fx-chain` after choosing a voice preset, or `null` to drop the attribute.
70
+ * Single choice: the other menu presets' nodes go; carve, leveller and hand-added nodes stay.
71
+ */
72
+ export function chainWithVoicePreset(
73
+ rawChain: string | null | undefined,
74
+ presetId: string | null,
75
+ ): string | null {
76
+ const chain = parseChainOrEmpty(rawChain);
77
+ const others = chain.nodes.filter(
78
+ (node) =>
79
+ !node.fromPreset || !VOICE_MENU_IDS.has(node.fromPreset) || node.fromPreset === presetId,
80
+ );
81
+ const kept: HfAudioFxChain = { ...chain, nodes: others };
82
+ const next = presetId ? (applyPresetToChain(kept, presetId, undefined) ?? kept) : kept;
83
+ return next.nodes.length > 0 ? serializeAudioFxChain(next) : null;
84
+ }
85
+
86
+ function parseJson(raw: string | null | undefined): unknown {
87
+ if (!raw) return null;
88
+ try {
89
+ return JSON.parse(raw);
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ export function activeLook(rawGrading: string | null | undefined): string | null {
96
+ return normalizeHfColorGrading(parseJson(rawGrading))?.preset ?? null;
97
+ }
98
+
99
+ function lookLabel(id: string | null): string | null {
100
+ return labelFor(LOOK_CHOICES, id);
101
+ }
102
+
103
+ export function lookAttrValue(presetId: string | null): string | null {
104
+ return presetId ? JSON.stringify({ preset: presetId, intensity: 1 }) : null;
105
+ }
106
+
107
+ function isRecord(value: unknown): value is Record<string, unknown> {
108
+ return typeof value === "object" && value !== null;
109
+ }
110
+
111
+ export function isDucked(rawCarve: string | null | undefined): boolean {
112
+ const carve = parseJson(rawCarve);
113
+ return isRecord(carve) && carve["enabled"] !== false;
114
+ }
115
+
116
+ function hasRateRamp(rawAutomation: string | null | undefined): boolean {
117
+ return parseRateLane(rawAutomation) !== null;
118
+ }
119
+
120
+ const ZERO_INSET = /^inset\(\s*0(px|%)?\s*\)$/i;
121
+
122
+ export function hasCrop(clipPath: string | null | undefined): boolean {
123
+ const value = clipPath?.trim() ?? "";
124
+ return value !== "" && value !== "none" && !ZERO_INSET.test(value);
125
+ }
126
+
127
+ export type ClipBadgeKind = "link" | "look" | "voice" | "ramp" | "crop" | "ducked" | "volume";
128
+
129
+ export interface ClipBadge {
130
+ kind: ClipBadgeKind;
131
+ label: string;
132
+ }
133
+
134
+ /** What a clip's attributes say is applied; the element's own attributes, as read off its node. */
135
+ export interface ClipToolState {
136
+ tag: string;
137
+ hasSound: boolean;
138
+ volume: number | null;
139
+ muted: boolean;
140
+ fxChain: string | null;
141
+ automation: string | null;
142
+ colorGrading: string | null;
143
+ clipPath: string | null;
144
+ carve: string | null;
145
+ link: string | null;
146
+ }
147
+
148
+ function volumeBadge(state: ClipToolState): ClipBadge | null {
149
+ const isAudio = state.tag === "audio";
150
+ if (!isAudio && !state.hasSound) return null;
151
+ if (state.muted) return { kind: "volume", label: "Muted" };
152
+ const volume = state.volume ?? 1;
153
+ if (Math.abs(volume - 1) < 0.005) return null;
154
+ return { kind: "volume", label: `${Math.round(volume * 100)}%` };
155
+ }
156
+
157
+ /** Badges in the wireframe's order: look, voice, ramp, crop, ducked, volume. The link badge leads. */
158
+ export function readClipBadges(state: ClipToolState): ClipBadge[] {
159
+ const look = lookLabel(activeLook(state.colorGrading));
160
+ const voice = voicePresetLabel(activeVoicePreset(state.fxChain));
161
+ const volume = volumeBadge(state);
162
+ const badges: Array<ClipBadge | null> = [
163
+ state.link ? { kind: "link", label: "Linked" } : null,
164
+ look ? { kind: "look", label: look } : null,
165
+ voice ? { kind: "voice", label: `Voice: ${voice}` } : null,
166
+ hasRateRamp(state.automation) ? { kind: "ramp", label: "Ramp" } : null,
167
+ hasCrop(state.clipPath) ? { kind: "crop", label: "Crop" } : null,
168
+ isDucked(state.carve) ? { kind: "ducked", label: "Ducked" } : null,
169
+ volume,
170
+ ];
171
+ return badges.filter((badge): badge is ClipBadge => badge !== null);
172
+ }
173
+
174
+ const MAX_VISIBLE_BADGES = 2;
175
+
176
+ /** The link badge is always shown; the rest cap at two, then `+N`. */
177
+ export function splitVisibleBadges(badges: readonly ClipBadge[]): {
178
+ visible: ClipBadge[];
179
+ hidden: ClipBadge[];
180
+ } {
181
+ const link = badges.filter((badge) => badge.kind === "link");
182
+ const rest = badges.filter((badge) => badge.kind !== "link");
183
+ return {
184
+ visible: [...link, ...rest.slice(0, MAX_VISIBLE_BADGES)],
185
+ hidden: rest.slice(MAX_VISIBLE_BADGES),
186
+ };
187
+ }
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { LINK_LABEL_COLORS, linkLabelColor } from "./linkLabelColor";
3
+
4
+ describe("linkLabelColor", () => {
5
+ it("has no colour for an unlinked clip", () => {
6
+ expect(linkLabelColor(undefined)).toBeNull();
7
+ expect(linkLabelColor("")).toBeNull();
8
+ });
9
+
10
+ it("walks the palette in lk-N order, wrapping", () => {
11
+ expect(linkLabelColor("lk-1")).toBe(LINK_LABEL_COLORS[0]);
12
+ expect(linkLabelColor("lk-2")).toBe(LINK_LABEL_COLORS[1]);
13
+ expect(linkLabelColor(`lk-${LINK_LABEL_COLORS.length + 1}`)).toBe(LINK_LABEL_COLORS[0]);
14
+ });
15
+
16
+ it("is deterministic for hand-written ids", () => {
17
+ expect(linkLabelColor("talk-pair")).toBe(linkLabelColor("talk-pair"));
18
+ expect(LINK_LABEL_COLORS).toContain(linkLabelColor("talk-pair"));
19
+ });
20
+ });
@@ -0,0 +1,24 @@
1
+ /** Link-group label colours; teal, violet, white and red belong to clips, selection and the sync badge. */
2
+ export const LINK_LABEL_COLORS = [
3
+ "var(--color-amber-500)",
4
+ "var(--color-sky-400)",
5
+ "var(--color-green-400)",
6
+ "var(--color-pink-400)",
7
+ "var(--color-orange-400)",
8
+ "var(--color-amber-300)",
9
+ ] as const;
10
+
11
+ function stableIndex(link: string): number {
12
+ const numbered = /^lk-(\d+)$/.exec(link);
13
+ if (numbered) return Number(numbered[1]) - 1;
14
+ let hash = 0;
15
+ for (const char of link) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
16
+ return hash;
17
+ }
18
+
19
+ /** The same colour for every member of a link group, from its id alone. */
20
+ export function linkLabelColor(link: string | undefined): string | null {
21
+ if (!link) return null;
22
+ const count = LINK_LABEL_COLORS.length;
23
+ return LINK_LABEL_COLORS[((stableIndex(link) % count) + count) % count] ?? null;
24
+ }
@@ -39,6 +39,14 @@ export interface TimelineDropCallbacks {
39
39
  ) => Promise<void> | void;
40
40
  }
41
41
 
42
+ export type TimelineLinkEdit =
43
+ | { kind: "unlink"; elements: readonly TimelineElement[] }
44
+ | { kind: "link"; elements: readonly TimelineElement[] }
45
+ | { kind: "detach"; element: TimelineElement }
46
+ | { kind: "merge"; video: TimelineElement; audio: TimelineElement }
47
+ | { kind: "move-into-sync"; element: TimelineElement; start: number }
48
+ | { kind: "slip-into-sync"; element: TimelineElement; mediaStart: number };
49
+
42
50
  export interface TimelineEditCallbacks {
43
51
  onMoveElement?: (
44
52
  element: TimelineElement,
@@ -115,9 +123,13 @@ export interface TimelineEditCallbacks {
115
123
  label: string,
116
124
  ) => Promise<TimelineEditOutcome | void>;
117
125
  onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
126
+ onLinkEdit?: (edit: TimelineLinkEdit) => Promise<void> | void;
127
+ onDeleteElementOnly?: (element: TimelineElement) => Promise<void> | void;
118
128
  onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
119
129
  onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
120
130
  onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
131
+ onFreezeFrame?: (element: TimelineElement, time: number) => Promise<void> | void;
132
+ onNotice?: (message: string, tone?: "error" | "info") => void;
121
133
  onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
122
134
  onDeleteAllKeyframes?: (element: TimelineElement, animationId?: string) => void;
123
135
  onMoveKeyframeToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
@@ -70,6 +70,7 @@ interface TimelineClipDragGestureLifecycleInput {
70
70
  onBlockedEditAttemptRef: RefObject<
71
71
  ((element: TimelineElement, intent: BlockedClipState["intent"]) => void) | undefined
72
72
  >;
73
+ onLinkEditRef: RefObject<TimelineEditCallbacks["onLinkEdit"]>;
73
74
  readZIndexRef: RefObject<((element: TimelineElement) => number) | undefined>;
74
75
  onStackingPatchesRef: RefObject<
75
76
  ((patches: StackingPatch[]) => Promise<unknown> | void) | undefined
@@ -107,6 +108,7 @@ export function mountTimelineClipDragGestureLifecycle({
107
108
  onResizeElementRef,
108
109
  onResizeElementsRef,
109
110
  onBlockedEditAttemptRef,
111
+ onLinkEditRef,
110
112
  readZIndexRef,
111
113
  onStackingPatchesRef,
112
114
  refreshAfterLaneMoveRef,
@@ -228,6 +230,11 @@ export function mountTimelineClipDragGestureLifecycle({
228
230
  if (drag && pointerMatchesGesture(event)) handleDragPointerMove(event, drag);
229
231
  };
230
232
 
233
+ const unlinkAltEdited = (gesture: { element: TimelineElement; altKey?: boolean }) => {
234
+ if (!gesture.altKey || !gesture.element.link) return;
235
+ void onLinkEditRef.current?.({ kind: "unlink", elements: [gesture.element] });
236
+ };
237
+
231
238
  const commitResizePointerUp = (
232
239
  resize: ResizingClipState,
233
240
  groupSession: TimelineGroupResizeSession | null,
@@ -271,6 +278,7 @@ export function mountTimelineClipDragGestureLifecycle({
271
278
  ]);
272
279
  console.error("[Timeline] Failed to persist clip resize", error);
273
280
  });
281
+ unlinkAltEdited(resize);
274
282
  };
275
283
 
276
284
  const finishBlockedPointerUp = (blocked: BlockedClipState) => {
@@ -293,6 +301,7 @@ export function mountTimelineClipDragGestureLifecycle({
293
301
  onStackingPatches: onStackingPatchesRef.current,
294
302
  refreshAfterLaneMove: refreshAfterLaneMoveRef.current,
295
303
  });
304
+ unlinkAltEdited(drag);
296
305
  };
297
306
 
298
307
  /** Group gestures commit atomically: one missing member cancels the whole resize. */
@@ -33,6 +33,7 @@ export interface DraggedClipState {
33
33
  snapTime: number | null;
34
34
  snapType: TimelineSnapType | null;
35
35
  started: boolean;
36
+ altKey?: boolean;
36
37
  }
37
38
 
38
39
  export interface ResizingClipState {
@@ -63,6 +64,7 @@ export interface ResizingClipState {
63
64
  playbackStart?: number;
64
65
  }[];
65
66
  started: boolean;
67
+ altKey?: boolean;
66
68
  }
67
69
 
68
70
  export interface BlockedClipState {
@@ -8,6 +8,7 @@ import {
8
8
  import type { TimelineEditCapabilities } from "./timelineEditCapabilities";
9
9
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
10
10
  import { CLIP_HANDLE_W } from "./timelineLayout";
11
+ import { selectClipWithLinks } from "./timelineLinkSelection";
11
12
  import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
12
13
 
13
14
  export interface ClipGestureDeps {
@@ -145,6 +146,7 @@ export function createClipGestureHandlers(
145
146
  previewDuration: el.duration,
146
147
  previewPlaybackStart: el.playbackStart,
147
148
  started: false,
149
+ altKey: e.altKey,
148
150
  });
149
151
  };
150
152
 
@@ -191,6 +193,7 @@ export function createClipGestureHandlers(
191
193
  snapTime: null,
192
194
  snapType: null,
193
195
  started: false,
196
+ altKey: e.altKey,
194
197
  });
195
198
  };
196
199
 
@@ -217,7 +220,7 @@ export function createClipGestureHandlers(
217
220
  return;
218
221
  }
219
222
  // Clip selection is idempotent; empty timeline space owns deselection.
220
- setSelectedElementId(elementKey);
223
+ selectClipWithLinks(elementKey, e.altKey, setSelectedElementId);
221
224
  onSelectElement?.(el);
222
225
  };
223
226
 
@@ -0,0 +1,23 @@
1
+ import { usePlayerStore } from "../store/playerStore";
2
+ import { expandToLinkedMembers } from "./audioClipLink";
3
+ import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
4
+
5
+ /**
6
+ * Select `key` the way a clip click does, then widen to link partners; Alt
7
+ * (or Linked Selection off) selects `key` alone, even inside a larger selection.
8
+ */
9
+ export function selectClipWithLinks(
10
+ key: string,
11
+ altKey: boolean,
12
+ setSelectedElementId: (id: string) => void,
13
+ ): void {
14
+ const state = usePlayerStore.getState();
15
+ if (altKey || !isLinkedSelectionOn()) {
16
+ state.setSelection([key], key);
17
+ return;
18
+ }
19
+ setSelectedElementId(key);
20
+ const { selectedElementIds, elements } = usePlayerStore.getState();
21
+ const expanded = expandToLinkedMembers(selectedElementIds, elements);
22
+ if (expanded.size > selectedElementIds.size) state.setSelection(expanded, key);
23
+ }
@@ -0,0 +1,92 @@
1
+ import { useEffect, useState } from "react";
2
+ import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
3
+ import { HF_AUDIO_CARVE_ATTR } from "@hyperframes/core/audio-carve";
4
+ import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
5
+ import { HF_COLOR_GRADING_ATTR } from "@hyperframes/core/color-grading";
6
+ import type { TimelineElement } from "../store/playerStore";
7
+ import { useLivePreviewIframe } from "../store/previewIframeStore";
8
+ import { useStudioShellContextOptional } from "../../contexts/StudioContext";
9
+ import { findTimelineElementInIframe } from "../../hooks/timelineEditingHelpers";
10
+ import { committedClipPath } from "../../components/editor/cropPresetStore";
11
+ import { isAudibleVideoNode } from "../lib/timelineElementHelpers";
12
+ import type { ClipToolState } from "./clipToolAttrs";
13
+
14
+ const LINK_ATTR = "data-link";
15
+
16
+ const WATCHED_ATTRS = [
17
+ HF_AUDIO_FX_ATTR,
18
+ HF_AUDIO_AUTOMATION_ATTR,
19
+ HF_AUDIO_CARVE_ATTR,
20
+ HF_COLOR_GRADING_ATTR,
21
+ LINK_ATTR,
22
+ "data-volume",
23
+ "data-has-audio",
24
+ "muted",
25
+ "style",
26
+ ];
27
+
28
+ function readVolume(node: Element): number | null {
29
+ const volume = Number.parseFloat(node.getAttribute("data-volume") ?? "");
30
+ return Number.isFinite(volume) ? volume : null;
31
+ }
32
+
33
+ function toolStateFromNode(node: Element): ClipToolState {
34
+ const tag = node.tagName.toLowerCase();
35
+ return {
36
+ tag,
37
+ hasSound: tag === "audio" || isAudibleVideoNode(node),
38
+ volume: readVolume(node),
39
+ muted: node.hasAttribute("muted"),
40
+ fxChain: node.getAttribute(HF_AUDIO_FX_ATTR),
41
+ automation: node.getAttribute(HF_AUDIO_AUTOMATION_ATTR),
42
+ colorGrading: node.getAttribute(HF_COLOR_GRADING_ATTR),
43
+ clipPath: committedClipPath(node),
44
+ carve: node.getAttribute(HF_AUDIO_CARVE_ATTR),
45
+ link: node.getAttribute(LINK_ATTR),
46
+ };
47
+ }
48
+
49
+ function toolStateFromElement(el: TimelineElement): ClipToolState {
50
+ const tag = el.tag.trim().toLowerCase();
51
+ return {
52
+ tag,
53
+ hasSound: tag === "audio" || el.hasAudio === true,
54
+ volume: el.volume ?? null,
55
+ muted: el.muted === true,
56
+ fxChain: el.fxChain ?? null,
57
+ automation: el.automation ?? null,
58
+ colorGrading: null,
59
+ clipPath: null,
60
+ carve: null,
61
+ link: null,
62
+ };
63
+ }
64
+
65
+ /** Inline style changes every frame under a tween; only a changed reading re-renders. */
66
+ function keepIfUnchanged(prev: ClipToolState, next: ClipToolState): ClipToolState {
67
+ return JSON.stringify(prev) === JSON.stringify(next) ? prev : next;
68
+ }
69
+
70
+ /** A clip's tool attributes as the live preview node holds them, kept current as they change. */
71
+ export function useClipToolState(el: TimelineElement): ClipToolState {
72
+ const iframe = useLivePreviewIframe();
73
+ const activeCompPath = useStudioShellContextOptional()?.activeCompPath ?? null;
74
+ const [state, setState] = useState<ClipToolState>(() => toolStateFromElement(el));
75
+
76
+ useEffect(() => {
77
+ const node = findTimelineElementInIframe(iframe, el, activeCompPath);
78
+ if (!node) {
79
+ setState(toolStateFromElement(el));
80
+ return;
81
+ }
82
+ const refresh = () => setState((prev) => keepIfUnchanged(prev, toolStateFromNode(node)));
83
+ refresh();
84
+ const view = node.ownerDocument.defaultView;
85
+ if (!view) return;
86
+ const observer = new view.MutationObserver(refresh);
87
+ observer.observe(node, { attributes: true, attributeFilter: WATCHED_ATTRS });
88
+ return () => observer.disconnect();
89
+ }, [iframe, el, activeCompPath]);
90
+
91
+ return state;
92
+ }
@@ -1,6 +1,7 @@
1
1
  import { useCallback } from "react";
2
2
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
3
3
  import type { ClipContextMenuState } from "./TimelineProvider";
4
+ import { selectClipWithLinks } from "./timelineLinkSelection";
4
5
 
5
6
  /** A clicked member of an active multi-selection keeps the whole group
6
7
  * selected, so the context menu it opens acts on all of it. */
@@ -16,7 +17,7 @@ export function useClipContextMenu(
16
17
  e.preventDefault();
17
18
  const id = el.key ?? el.id;
18
19
  if (!(selectedElementIds.size > 1 && selectedElementIds.has(id))) {
19
- setSelectedElementId(id);
20
+ selectClipWithLinks(id, e.altKey, setSelectedElementId);
20
21
  onSelectElement?.(el);
21
22
  }
22
23
  dismissGapMenu();
@@ -27,6 +27,8 @@ import type {
27
27
  BlockedClipState,
28
28
  } from "./timelineClipDragTypes";
29
29
  import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
30
+ import { linkedGestureKeys } from "./audioClipLink";
31
+ import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
30
32
  import type { TimelineRowGeometry } from "./timelineLayout";
31
33
  import {
32
34
  mountTimelineClipDragGestureLifecycle,
@@ -65,6 +67,7 @@ interface UseTimelineClipDragInput {
65
67
  ) => Promise<void> | void;
66
68
  onResizeElements?: NonNullable<TimelineEditCallbacks["onResizeElements"]>;
67
69
  onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedClipState["intent"]) => void;
70
+ onLinkEdit?: TimelineEditCallbacks["onLinkEdit"];
68
71
  /** Seeks the preview; a trim shows the frame at its dragged edge. */
69
72
  onSeek?: (time: number, options?: { keepPlaying?: boolean; follow?: boolean }) => void;
70
73
  setShowPopover: (show: boolean) => void;
@@ -95,6 +98,7 @@ export function useTimelineClipDrag({
95
98
  onResizeElement,
96
99
  onResizeElements,
97
100
  onBlockedEditAttempt,
101
+ onLinkEdit,
98
102
  onSeek,
99
103
  setShowPopover,
100
104
  setRangeSelectionRef,
@@ -220,6 +224,13 @@ export function useTimelineClipDrag({
220
224
  return;
221
225
  }
222
226
  beginGesture("drag", next.pointerId);
227
+ gestureSelectedKeysRef.current = linkedGestureKeys(
228
+ gestureSelectedKeysRef.current,
229
+ next.element,
230
+ elementsRef.current,
231
+ next.altKey === true,
232
+ isLinkedSelectionOn(),
233
+ );
223
234
  publishDraggedClip(next);
224
235
  },
225
236
  [beginGesture, publishDraggedClip],
@@ -231,6 +242,13 @@ export function useTimelineClipDrag({
231
242
  return;
232
243
  }
233
244
  beginGesture("resize", next.pointerId);
245
+ gestureSelectedKeysRef.current = linkedGestureKeys(
246
+ gestureSelectedKeysRef.current,
247
+ next.element,
248
+ elementsRef.current,
249
+ next.altKey === true,
250
+ isLinkedSelectionOn(),
251
+ );
234
252
  publishResizingClip(next);
235
253
  },
236
254
  [beginGesture, publishResizingClip],
@@ -249,6 +267,8 @@ export function useTimelineClipDrag({
249
267
  onMoveElementsRef.current = onMoveElements;
250
268
  const onBlockedEditAttemptRef = useRef(onBlockedEditAttempt);
251
269
  onBlockedEditAttemptRef.current = onBlockedEditAttempt;
270
+ const onLinkEditRef = useRef(onLinkEdit);
271
+ onLinkEditRef.current = onLinkEdit;
252
272
  const onResizeElementRef = useRef(onResizeElement);
253
273
  onResizeElementRef.current = onResizeElement;
254
274
  const onResizeElementsRef = useRef(onResizeElements);
@@ -422,6 +442,7 @@ export function useTimelineClipDrag({
422
442
  refreshAfterLaneMoveRef,
423
443
  readZIndexRef,
424
444
  onBlockedEditAttemptRef,
445
+ onLinkEditRef,
425
446
  onResizeElementsRef,
426
447
  onResizeElementRef,
427
448
  onMoveElementsRef,
@@ -86,6 +86,7 @@ export function useTimelineProviderState({
86
86
  onResizeElement,
87
87
  onResizeElements,
88
88
  onBlockedEditAttempt,
89
+ onLinkEdit,
89
90
  onSplitElement,
90
91
  onRazorSplitAll,
91
92
  onDeleteKeyframe,
@@ -105,6 +106,7 @@ export function useTimelineProviderState({
105
106
  const readOnlyPress = useTimelineReadOnlyPress();
106
107
  const oneShotEdits = refuseWhenReadOnly(readOnlyPress, {
107
108
  onBlockedEditAttempt,
109
+ onLinkEdit,
108
110
  onRazorSplitAll,
109
111
  onToggleTrackHidden: editContext.onToggleTrackHidden,
110
112
  onTogglePropertyGroupKeyframe: editContext.onTogglePropertyGroupKeyframe,
@@ -217,6 +219,7 @@ export function useTimelineProviderState({
217
219
  onResizeElement: pinnedOnResizeElement,
218
220
  onResizeElements: pinnedOnResizeElements,
219
221
  onBlockedEditAttempt: oneShotEdits.onBlockedEditAttempt,
222
+ onLinkEdit: oneShotEdits.onLinkEdit,
220
223
  onSeek,
221
224
  setShowPopover,
222
225
  setRangeSelectionRef,
@@ -13,9 +13,15 @@ describe("timelineElementsChanged", () => {
13
13
  ["audio group hidden", { audioGroupHidden: true }],
14
14
  ["fade in", { fadeIn: 1 }],
15
15
  ["fade out", { fadeOut: 1 }],
16
+ ["link", { link: "lk-1" }],
16
17
  ["source (re-pointed at its preview copy)", { src: "clip1.mp4?hf-proxy=h264" }],
17
18
  ])("sees a clip whose %s changed with no timing change", (_name, change) => {
18
19
  expect(timelineElementsChanged([clip], [{ ...clip, ...change }])).toBe(true);
19
20
  expect(timelineElementsChanged([{ ...clip, ...change }], [{ ...clip, ...change }])).toBe(false);
20
21
  });
22
+
23
+ it("sees an unlink: the same clips re-derived without their link", () => {
24
+ const linked = { ...clip, link: "lk-1" };
25
+ expect(timelineElementsChanged([linked], [clip])).toBe(true);
26
+ });
21
27
  });
@@ -16,6 +16,7 @@ const RENDERED_FIELDS: readonly (keyof TimelineElement)[] = [
16
16
  "fadeIn",
17
17
  "fadeOut",
18
18
  "src",
19
+ "link",
19
20
  ];
20
21
 
21
22
  /** Whether a derived timeline changes any field that affects rendering. */
@@ -15,6 +15,7 @@ import type { PlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
15
15
  import { readTimelineDurationFromDocument } from "../lib/timelineDOM";
16
16
  import { buildMissingCompositionElements } from "../lib/timelineIframeHelpers";
17
17
  import { acceptedRuntimeMessageFps } from "../lib/runtimeProtocol";
18
+ import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
18
19
  import {
19
20
  buildTimelineElementsFromClips,
20
21
  syncManifestTimeline,
@@ -196,8 +197,10 @@ export function useTimelineSyncCallbacks({
196
197
  // at the authored root `data-duration` so a runtime that measures only the
197
198
  // furthest clip end (shorter than the authored window) can't leave a stale,
198
199
  // too-short total in the transport (the "0:44/0:40" bug).
200
+ const fps = acceptedRuntimeMessageFps(data);
201
+ useLinkedClipPreferences.getState().setCompositionFps(fps);
199
202
  const newDuration = resolveTimelineTotalDuration({
200
- manifestDurationSeconds: data.durationInFrames / acceptedRuntimeMessageFps(data),
203
+ manifestDurationSeconds: data.durationInFrames / fps,
201
204
  authoredRootDurationSeconds: readTimelineDurationFromDocument(iframeDoc),
202
205
  });
203
206
  syncManifestTimeline(
@@ -281,6 +281,37 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
281
281
 
282
282
  expect(element.hidden).toBe(true);
283
283
  });
284
+
285
+ it("reads data-link and data-sync-origin on both the DOM and manifest paths", () => {
286
+ const doc = makeDoc(`
287
+ <div data-composition-id="root">
288
+ <video id="talk" class="clip" src="t.mp4" muted data-link="lk-1" data-sync-origin="lk-1" data-start="0" data-duration="5"></video>
289
+ </div>
290
+ `);
291
+ const fromDom = parseTimelineFromDOM(doc, 10).find((el) => el.domId === "talk");
292
+ expect(fromDom?.link).toBe("lk-1");
293
+ expect(fromDom?.syncOrigin).toBe("lk-1");
294
+ const element = createTimelineElementFromManifestClip({
295
+ clip: {
296
+ id: "talk",
297
+ label: "Talk",
298
+ kind: "element",
299
+ tagName: "video",
300
+ start: 0,
301
+ duration: 5,
302
+ track: 0,
303
+ compositionId: null,
304
+ parentCompositionId: null,
305
+ compositionSrc: null,
306
+ assetUrl: null,
307
+ },
308
+ fallbackIndex: 0,
309
+ doc,
310
+ hostEl: doc.getElementById("talk"),
311
+ });
312
+ expect(element.link).toBe("lk-1");
313
+ expect(element.syncOrigin).toBe("lk-1");
314
+ });
284
315
  });
285
316
 
286
317
  describe("group info cache", () => {