@hyperframes/studio 0.7.58 → 0.7.60

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 (173) hide show
  1. package/dist/assets/{hyperframes-player-CtTDO63S.js → hyperframes-player-3XTTaVNf.js} +1 -1
  2. package/dist/assets/{index-C47jAC3Q.js → index-D6etaey-.js} +1 -1
  3. package/dist/assets/index-DXbu6IPT.css +1 -0
  4. package/dist/assets/{index-DeQPzqwH.js → index-Dh_WhagG.js} +1 -1
  5. package/dist/assets/index-cH6NfVV_.js +426 -0
  6. package/dist/index.d.ts +12 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +13755 -8627
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.d.ts +5 -0
  11. package/dist/styles/tailwind-preset.js +8 -1
  12. package/dist/styles/tailwind-preset.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +4 -4
  15. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  16. package/src/components/StudioRightPanel.tsx +42 -53
  17. package/src/components/editor/AnimationCard.test.tsx +134 -0
  18. package/src/components/editor/AnimationCard.tsx +19 -4
  19. package/src/components/editor/ArcPathControls.tsx +1 -0
  20. package/src/components/editor/GestureRecordControl.tsx +7 -1
  21. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  22. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  23. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  24. package/src/components/editor/PropertyPanel.tsx +144 -148
  25. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  26. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  27. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  28. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  29. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  30. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  31. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  32. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  33. package/src/components/editor/gsapLivePreview.ts +34 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  35. package/src/components/editor/manualEditingAvailability.ts +9 -0
  36. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  37. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  38. package/src/components/editor/propertyPanelColor.tsx +31 -1
  39. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  40. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  41. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  42. package/src/components/editor/propertyPanelFill.tsx +22 -5
  43. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  44. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  45. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  46. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  47. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  48. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  49. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  50. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  51. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  52. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  53. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  54. package/src/components/editor/propertyPanelFlatSelectRow.tsx +107 -0
  55. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  56. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  57. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  58. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  59. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  60. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  61. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  62. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  63. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  64. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  65. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  66. package/src/components/editor/propertyPanelFont.tsx +136 -95
  67. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  68. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  69. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  70. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  71. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  72. package/src/components/editor/propertyPanelSections.tsx +154 -90
  73. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  74. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  75. package/src/components/editor/propertyPanelTypes.ts +14 -1
  76. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  77. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  78. package/src/components/editor/useColorGradingController.test.ts +418 -0
  79. package/src/components/editor/useColorGradingController.ts +596 -0
  80. package/src/components/panels/VariablesPanel.tsx +4 -0
  81. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  82. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  83. package/src/components/storyboard/FramePoster.tsx +24 -4
  84. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  85. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  86. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  87. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  88. package/src/components/storyboard/StoryboardView.tsx +9 -2
  89. package/src/components/storyboard/frameComments.test.ts +95 -0
  90. package/src/components/storyboard/frameComments.ts +115 -0
  91. package/src/components/storyboard/useFrameComments.ts +82 -0
  92. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  93. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  94. package/src/contexts/DomEditContext.tsx +4 -0
  95. package/src/contexts/FileManagerContext.tsx +3 -0
  96. package/src/contexts/PanelLayoutContext.tsx +3 -0
  97. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  98. package/src/contexts/VariablePromoteContext.tsx +10 -5
  99. package/src/hooks/domEditCommitRunner.ts +11 -0
  100. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  101. package/src/hooks/timelineEditingHelpers.ts +2 -2
  102. package/src/hooks/timelineTrackVisibility.test.ts +52 -0
  103. package/src/hooks/timelineTrackVisibility.ts +27 -10
  104. package/src/hooks/useAppHotkeys.ts +9 -0
  105. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  106. package/src/hooks/useDomEditCommits.test.tsx +41 -1
  107. package/src/hooks/useDomEditCommits.ts +31 -177
  108. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  109. package/src/hooks/useDomEditSession.ts +18 -6
  110. package/src/hooks/useDomEditTextCommits.ts +14 -9
  111. package/src/hooks/useDomEditWiring.ts +10 -9
  112. package/src/hooks/useEditorSave.ts +1 -1
  113. package/src/hooks/useElementLifecycleOps.ts +8 -3
  114. package/src/hooks/useFileManager.projectOwnership.test.tsx +109 -0
  115. package/src/hooks/useFileManager.ts +151 -65
  116. package/src/hooks/useGsapAnimationOps.ts +7 -6
  117. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  118. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  119. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  120. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  121. package/src/hooks/useGsapScriptCommits.ts +150 -50
  122. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  123. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  124. package/src/hooks/useInspectorSplitResize.ts +51 -0
  125. package/src/hooks/usePanelLayout.test.ts +118 -0
  126. package/src/hooks/usePanelLayout.ts +24 -1
  127. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  128. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  129. package/src/hooks/usePersistentEditHistory.ts +71 -31
  130. package/src/hooks/usePreviewPersistence.ts +4 -1
  131. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  132. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  133. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  134. package/src/hooks/useRazorSplit.history.test.tsx +12 -4
  135. package/src/hooks/useRazorSplit.test.tsx +88 -0
  136. package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
  137. package/src/hooks/useRazorSplit.ts +50 -16
  138. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  139. package/src/hooks/useSdkSession.ts +155 -14
  140. package/src/hooks/useSlideshowPersist.ts +7 -1
  141. package/src/hooks/useStoryboard.ts +23 -4
  142. package/src/hooks/useTimelineEditing.test.tsx +4 -1
  143. package/src/hooks/useTimelineEditing.ts +18 -10
  144. package/src/hooks/useTimelineEditingTypes.ts +5 -1
  145. package/src/hooks/useTimelineGroupEditing.ts +17 -3
  146. package/src/hooks/useVariablesPersist.ts +9 -7
  147. package/src/icons/SystemIcons.tsx +2 -0
  148. package/src/styles/studio.css +22 -0
  149. package/src/styles/tailwind-preset.shared.js +7 -0
  150. package/src/utils/designInputTracking.test.ts +97 -0
  151. package/src/utils/designInputTracking.ts +80 -0
  152. package/src/utils/domEditSaveQueue.test.ts +19 -0
  153. package/src/utils/domEditSaveQueue.ts +2 -1
  154. package/src/utils/sdkCutover.gate.test.ts +7 -7
  155. package/src/utils/sdkCutover.test.ts +511 -62
  156. package/src/utils/sdkCutover.ts +181 -219
  157. package/src/utils/sdkEditTransaction.ts +293 -0
  158. package/src/utils/sdkResolverAttempts.ts +94 -0
  159. package/src/utils/sdkResolverShadow.test.ts +148 -0
  160. package/src/utils/sdkResolverShadow.ts +139 -106
  161. package/src/utils/setSlideshowManifest.test.ts +0 -9
  162. package/src/utils/setSlideshowManifest.ts +20 -27
  163. package/src/utils/studioFileHistory.test.ts +41 -0
  164. package/src/utils/studioFileHistory.ts +34 -29
  165. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  166. package/src/utils/studioFileVersion.test.ts +36 -0
  167. package/src/utils/studioFileVersion.ts +23 -0
  168. package/src/utils/studioHelpers.test.ts +45 -0
  169. package/src/utils/studioHelpers.ts +45 -14
  170. package/src/utils/studioSaveDiagnostics.test.ts +18 -0
  171. package/src/utils/studioSaveDiagnostics.ts +38 -0
  172. package/dist/assets/index-B_UvTX3E.js +0 -423
  173. package/dist/assets/index-uahwWkgw.css +0 -1
@@ -0,0 +1,596 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
2
+ import {
3
+ HF_COLOR_GRADING_ATTR,
4
+ isHfColorGradingActive,
5
+ normalizeHfColorGrading,
6
+ serializeHfColorGrading,
7
+ type HfColorGradingTarget,
8
+ type NormalizedHfColorGrading,
9
+ } from "@hyperframes/core/color-grading";
10
+ import {
11
+ addStudioPendingEditFlushListener,
12
+ trackStudioPendingEdit,
13
+ } from "../../utils/studioPendingEdits";
14
+ import type { DomEditSelection } from "./domEditing";
15
+ import { selectionIdentityKey, stripQueryAndHash } from "./propertyPanelHelpers";
16
+ import { bumpDomEditCommitVersion } from "../../hooks/domEditCommitRunner";
17
+ import {
18
+ acceptStudioRuntimeMessage,
19
+ postRuntimeControlMessage,
20
+ } from "../../player/lib/runtimeProtocol";
21
+
22
+ const COLOR_GRADING_DATA_KEY = HF_COLOR_GRADING_ATTR.replace(/^data-/, "");
23
+ const RUNTIME_STATUS_REFRESH_DELAYS = [50, 250, 1000, 2500] as const;
24
+ const MEDIA_METADATA_CACHE = new Map<string, MediaMetadata | null>();
25
+
26
+ export interface RuntimeColorGradingStatus {
27
+ state: "missing" | "inactive" | "pending" | "active" | "unavailable";
28
+ message: string;
29
+ }
30
+
31
+ export interface MediaMetadata {
32
+ kind: "video" | "image" | "audio" | "unknown";
33
+ color: {
34
+ dynamicRange: "hdr" | "sdr" | "unknown";
35
+ hdrTransfer: "pq" | "hlg" | "unknown" | null;
36
+ label: string;
37
+ isHdr: boolean;
38
+ codecName?: string;
39
+ profile?: string;
40
+ pixelFormat?: string;
41
+ colorSpace?: string;
42
+ colorTransfer?: string;
43
+ colorPrimaries?: string;
44
+ };
45
+ probeError?: string;
46
+ }
47
+
48
+ interface MediaMetadataResponse {
49
+ path: string;
50
+ metadata: MediaMetadata;
51
+ }
52
+
53
+ function stripPreviewAssetPath(src: string, projectId: string): string | null {
54
+ let pathname = src;
55
+ try {
56
+ pathname = new URL(src, window.location.href).pathname;
57
+ } catch {
58
+ return null;
59
+ }
60
+ const projectMarker = `/api/projects/${encodeURIComponent(projectId)}/preview/`;
61
+ const genericMarker = "/preview/";
62
+ const marker = pathname.includes(projectMarker) ? projectMarker : genericMarker;
63
+ const index = pathname.indexOf(marker);
64
+ if (index < 0) return null;
65
+ const assetPath = decodeURIComponent(pathname.slice(index + marker.length)).replace(/^\/+/, "");
66
+ if (!assetPath || assetPath.startsWith("comp/")) return null;
67
+ return assetPath;
68
+ }
69
+
70
+ // fallow-ignore-next-line complexity
71
+ function resolveProjectAssetPath(
72
+ sourceFile: string,
73
+ src: string,
74
+ projectId: string,
75
+ ): string | null {
76
+ const trimmed = stripQueryAndHash(src.trim());
77
+ if (!trimmed || /^(?:data:|blob:)/i.test(trimmed)) return null;
78
+ if (/^https?:\/\//i.test(trimmed)) return stripPreviewAssetPath(trimmed, projectId);
79
+ if (trimmed.startsWith("/")) {
80
+ return stripPreviewAssetPath(trimmed, projectId);
81
+ }
82
+
83
+ const sourceDir = sourceFile.includes("/")
84
+ ? sourceFile.slice(0, sourceFile.lastIndexOf("/"))
85
+ : "";
86
+ const parts = `${sourceDir}/${trimmed}`.split("/");
87
+ const normalized: string[] = [];
88
+ for (const part of parts) {
89
+ if (!part || part === ".") continue;
90
+ if (part === "..") {
91
+ normalized.pop();
92
+ continue;
93
+ }
94
+ normalized.push(part);
95
+ }
96
+ return normalized.join("/") || null;
97
+ }
98
+
99
+ function selectedMediaAssetPath(element: DomEditSelection, projectId: string): string | null {
100
+ if (element.tagName !== "video" && element.tagName !== "img") return null;
101
+ const media = element.element as HTMLImageElement | HTMLVideoElement;
102
+ const src = media.getAttribute("src") || media.currentSrc || "";
103
+ return resolveProjectAssetPath(element.sourceFile || "index.html", src, projectId);
104
+ }
105
+
106
+ function defaultColorGrading(): NormalizedHfColorGrading {
107
+ const grading = normalizeHfColorGrading("neutral");
108
+ if (!grading) throw new Error("Missing neutral color grading preset");
109
+ return grading;
110
+ }
111
+
112
+ function readColorGradingFromElement(element: DomEditSelection): NormalizedHfColorGrading {
113
+ return (
114
+ normalizeHfColorGrading(element.dataAttributes[COLOR_GRADING_DATA_KEY]) ?? defaultColorGrading()
115
+ );
116
+ }
117
+
118
+ function toBridgeColorGrading(grading: NormalizedHfColorGrading): unknown {
119
+ if (!isHfColorGradingActive(grading)) return null;
120
+ const { enabled: _enabled, ...bridgeGrading } = grading;
121
+ return bridgeGrading;
122
+ }
123
+
124
+ function readRuntimeColorGradingStatus(
125
+ iframe: HTMLIFrameElement | null | undefined,
126
+ target: HfColorGradingTarget,
127
+ ): RuntimeColorGradingStatus {
128
+ try {
129
+ const win = iframe?.contentWindow as
130
+ | (Window & {
131
+ __hf?: {
132
+ colorGrading?: {
133
+ getStatus?: (
134
+ target: HfColorGradingTarget | string | null | undefined,
135
+ ) => RuntimeColorGradingStatus;
136
+ };
137
+ };
138
+ })
139
+ | null
140
+ | undefined;
141
+ const status = win?.__hf?.colorGrading?.getStatus?.(target);
142
+ return status ?? { state: "pending", message: "Waiting for runtime" };
143
+ } catch {
144
+ return { state: "unavailable", message: "Preview unavailable" };
145
+ }
146
+ }
147
+
148
+ export interface ColorGradingControllerState {
149
+ grading: NormalizedHfColorGrading;
150
+ compareEnabled: boolean;
151
+ applyScope: "source-file" | "project";
152
+ applyBusy: boolean;
153
+ runtimeStatus: RuntimeColorGradingStatus;
154
+ mediaMetadata: MediaMetadata | null;
155
+ commitColorGrading: (next: NormalizedHfColorGrading) => void;
156
+ commitCompare: (enabled: boolean) => void;
157
+ setApplyScope: (scope: "source-file" | "project") => void;
158
+ applyToScope: () => Promise<void>;
159
+ resetGrading: () => void;
160
+ }
161
+
162
+ export function useColorGradingController({
163
+ projectId,
164
+ element,
165
+ previewIframeRef,
166
+ onSetAttributeLive,
167
+ onApplyScope,
168
+ }: {
169
+ projectId: string;
170
+ element: DomEditSelection;
171
+ previewIframeRef?: RefObject<HTMLIFrameElement | null>;
172
+ onSetAttributeLive: (
173
+ attr: string,
174
+ value: string | null,
175
+ onSettled?: (ok: boolean) => void,
176
+ ) => void | Promise<void>;
177
+ onApplyScope?: (
178
+ scope: "source-file" | "project",
179
+ value: string | null,
180
+ ) => Promise<{ changedFiles: number; changedElements: number }>;
181
+ }): ColorGradingControllerState {
182
+ const [grading, setGrading] = useState(() => readColorGradingFromElement(element));
183
+ const [compareEnabled, setCompareEnabled] = useState(false);
184
+ const [applyScope, setApplyScope] = useState<"source-file" | "project">("source-file");
185
+ const [applyBusy, setApplyBusy] = useState(false);
186
+ const [runtimeStatus, setRuntimeStatus] = useState<RuntimeColorGradingStatus>(() => ({
187
+ state: "pending",
188
+ message: "Waiting for runtime",
189
+ }));
190
+ const selectedAssetPath = useMemo(
191
+ () => selectedMediaAssetPath(element, projectId),
192
+ [element, projectId],
193
+ );
194
+ const [mediaMetadata, setMediaMetadata] = useState<MediaMetadata | null>(null);
195
+ const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
196
+ const pendingPersistValueRef = useRef<string | null | undefined>(undefined);
197
+ const pendingPersistGradingRef = useRef<NormalizedHfColorGrading | null>(null);
198
+ // Identity the pending edit was made FOR, snapshotted at schedule time —
199
+ // read by a global flush instead of identityKeyRef.current, which may no
200
+ // longer describe this edit's element by the time the flush runs.
201
+ const pendingPersistIdentityRef = useRef<string | null>(null);
202
+ // The last grading value actually confirmed saved — distinct from `grading`
203
+ // (the optimistic value shown immediately on commit). A rejected persist
204
+ // reverts to this instead of leaving the UI permanently showing a value
205
+ // that was never written.
206
+ const confirmedGradingRef = useRef(grading);
207
+ // Monotonic per-commit version — guards against TWO edits on the SAME
208
+ // element racing (not just a selection change). If edit A's persist is
209
+ // still in flight when edit B commits, A's eventual settle must not stamp
210
+ // confirmedGradingRef with its now-superseded value or revert `grading`
211
+ // out from under B's newer optimistic state.
212
+ const gradingVersionRef = useRef(0);
213
+ const statusTimersRef = useRef<number[]>([]);
214
+ const latestGradingRef = useRef(grading);
215
+ const compareEnabledRef = useRef(compareEnabled);
216
+ latestGradingRef.current = grading;
217
+ compareEnabledRef.current = compareEnabled;
218
+
219
+ // Reset all per-element STATE when the selection changes to a different
220
+ // element — unlike the legacy ColorGradingSection (remounted via a
221
+ // `key={selectionIdentityKey(element)}` from its parent), this hook is
222
+ // called unconditionally on every render, so nothing naturally remounts it.
223
+ // Without this, switching selection reuses the previous element's grading/
224
+ // compare/mediaMetadata state. Adjusting state during render (comparing
225
+ // against a ref) is React's documented pattern for STATE updates
226
+ // specifically — it resolves in the same render pass instead of flashing
227
+ // stale state for one frame, and is safe to repeat if React discards and
228
+ // re-runs this render, since every value here is a pure function of
229
+ // `element`. It must NOT be used for side effects or for consuming
230
+ // shared mutable state (like the pending-persist timer/value) — a
231
+ // discarded render would have already consumed them with no corresponding
232
+ // effect ever running to compensate. That part happens below, in an
233
+ // effect's cleanup, which is guaranteed to run only for a render that
234
+ // actually committed.
235
+ const identityKey = selectionIdentityKey(element);
236
+ const identityKeyRef = useRef(identityKey);
237
+ if (identityKeyRef.current !== identityKey) {
238
+ identityKeyRef.current = identityKey;
239
+ const freshGrading = readColorGradingFromElement(element);
240
+ latestGradingRef.current = freshGrading;
241
+ confirmedGradingRef.current = freshGrading;
242
+ setGrading(freshGrading);
243
+ setCompareEnabled(false);
244
+ compareEnabledRef.current = false;
245
+ setApplyScope("source-file");
246
+ setApplyBusy(false);
247
+ setRuntimeStatus({ state: "pending", message: "Waiting for runtime" });
248
+ setMediaMetadata(null);
249
+ }
250
+
251
+ // Flushes — never discards — a still-pending edit when selection moves to
252
+ // a different element, and cancels the debounce timer. Implemented as an
253
+ // effect CLEANUP (not the render-phase block above, and not a queued ref
254
+ // consumed by a separate effect): a cleanup only ever runs for the
255
+ // specific effect instance that actually committed for `identityKey`, so
256
+ // there's no window where a discarded/interrupted render could have
257
+ // already consumed pendingPersistValueRef without this ever running to
258
+ // compensate. The cleanup's closure captures onSetAttributeLive/target
259
+ // bound to the OUTGOING identity, since it runs before the next effect
260
+ // instance (for the NEW identity) is established.
261
+ useEffect(() => {
262
+ return () => {
263
+ // Stale runtime-status timers scheduled for the OUTGOING element must
264
+ // not fire after this: readRuntimeColorGradingStatus closes over
265
+ // `target`, so an old timer firing post-switch would stamp the NEW
266
+ // element's runtimeStatus with the OLD element's answer.
267
+ for (const timer of statusTimersRef.current) clearTimeout(timer);
268
+ statusTimersRef.current = [];
269
+ if (persistTimerRef.current) {
270
+ clearTimeout(persistTimerRef.current);
271
+ persistTimerRef.current = null;
272
+ }
273
+ if (pendingPersistValueRef.current === undefined) return;
274
+ const value = pendingPersistValueRef.current;
275
+ pendingPersistValueRef.current = undefined;
276
+ pendingPersistGradingRef.current = null;
277
+ pendingPersistIdentityRef.current = null;
278
+ trackStudioPendingEdit(onSetAttributeLive(COLOR_GRADING_DATA_KEY, value));
279
+ };
280
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- identityKey is the intended trigger; see comment above
281
+ }, [identityKey]);
282
+
283
+ const target = useMemo(
284
+ (): HfColorGradingTarget => ({
285
+ id: element.id ?? null,
286
+ hfId: element.hfId ?? null,
287
+ selector: element.selector ?? null,
288
+ selectorIndex: element.selectorIndex ?? null,
289
+ }),
290
+ [element.hfId, element.id, element.selector, element.selectorIndex],
291
+ );
292
+
293
+ const refreshRuntimeStatus = useCallback(() => {
294
+ setRuntimeStatus(readRuntimeColorGradingStatus(previewIframeRef?.current, target));
295
+ }, [previewIframeRef, target]);
296
+
297
+ useEffect(() => {
298
+ setMediaMetadata(null);
299
+ if (!selectedAssetPath) return;
300
+ const cacheKey = `${projectId}:${selectedAssetPath}`;
301
+ if (MEDIA_METADATA_CACHE.has(cacheKey)) {
302
+ setMediaMetadata(MEDIA_METADATA_CACHE.get(cacheKey) ?? null);
303
+ return;
304
+ }
305
+ const controller = new AbortController();
306
+ fetch(
307
+ `/api/projects/${encodeURIComponent(projectId)}/media/metadata?path=${encodeURIComponent(
308
+ selectedAssetPath,
309
+ )}`,
310
+ { signal: controller.signal },
311
+ )
312
+ .then(async (response) => {
313
+ if (!response.ok) return { ok: false as const };
314
+ const data: MediaMetadataResponse | null = await response.json();
315
+ return { ok: true as const, metadata: data?.metadata ?? null };
316
+ })
317
+ .then((result) => {
318
+ if (controller.signal.aborted) return;
319
+ // Only cache a definitive answer from a successful response — a non-OK
320
+ // status is a transient/server failure, not a stable "no metadata"
321
+ // result, and caching it would suppress the HDR banner for this asset
322
+ // for the page's whole lifetime. Leave the key absent so the next
323
+ // selection retries.
324
+ if (!result.ok) {
325
+ setMediaMetadata(null);
326
+ return;
327
+ }
328
+ MEDIA_METADATA_CACHE.set(cacheKey, result.metadata);
329
+ setMediaMetadata(result.metadata);
330
+ })
331
+ .catch(() => {
332
+ // Same reasoning as the non-OK branch above: don't cache a network-
333
+ // level fetch failure either.
334
+ if (!controller.signal.aborted) setMediaMetadata(null);
335
+ });
336
+ return () => controller.abort();
337
+ }, [projectId, selectedAssetPath]);
338
+
339
+ const clearStatusTimers = useCallback(() => {
340
+ for (const timer of statusTimersRef.current) clearTimeout(timer);
341
+ statusTimersRef.current = [];
342
+ }, []);
343
+
344
+ const scheduleRuntimeStatusRefresh = useCallback(() => {
345
+ clearStatusTimers();
346
+ statusTimersRef.current = RUNTIME_STATUS_REFRESH_DELAYS.map((delay) =>
347
+ window.setTimeout(refreshRuntimeStatus, delay),
348
+ );
349
+ }, [clearStatusTimers, refreshRuntimeStatus]);
350
+
351
+ useEffect(() => {
352
+ refreshRuntimeStatus();
353
+ }, [refreshRuntimeStatus]);
354
+
355
+ const persistColorGradingValue = useCallback(
356
+ (
357
+ value: string | null,
358
+ attemptedGrading: NormalizedHfColorGrading,
359
+ attemptIdentityKey: string,
360
+ isLatestAttempt: () => boolean,
361
+ setAttributeLive: (
362
+ attr: string,
363
+ value: string | null,
364
+ onSettled?: (ok: boolean) => void,
365
+ ) => void | Promise<void>,
366
+ ) => {
367
+ // Two guards, not one: identity (selection moved to a DIFFERENT
368
+ // element — that element already got its own confirmedGradingRef
369
+ // baseline from the reset block) and version (a NEWER edit landed on
370
+ // the SAME element — e.g. the user dragged Exposure, then Contrast,
371
+ // before Exposure's persist settled; Exposure settling afterward must
372
+ // not stamp confirmedGradingRef with its now-superseded value or
373
+ // revert `grading` out from under Contrast's newer optimistic state).
374
+ const applySettled = (ok: boolean) => {
375
+ if (identityKeyRef.current !== attemptIdentityKey) return;
376
+ if (!isLatestAttempt()) return;
377
+ if (ok) {
378
+ confirmedGradingRef.current = attemptedGrading;
379
+ return;
380
+ }
381
+ // Persist failed — the optimistic grading was never actually saved.
382
+ // Revert to the last confirmed-good value instead of leaving the
383
+ // control showing an unsaved state as if it succeeded.
384
+ const reverted = confirmedGradingRef.current;
385
+ latestGradingRef.current = reverted;
386
+ setGrading(reverted);
387
+ setRuntimeStatus({ state: "unavailable", message: "Save failed — reverted" });
388
+ };
389
+ // `onSettled` is the real signal — the underlying commit runner
390
+ // (runDomEditCommit) intentionally swallows persist failures so a
391
+ // caller `await`-ing this promise never sees a rejection; a rejection
392
+ // handler here alone would be dead code against the actual Studio
393
+ // callback. The `.catch` below is a fallback for any OTHER
394
+ // implementation of onSetAttributeLive that rejects instead.
395
+ // `setAttributeLive` is passed in by the caller rather than read from a
396
+ // ref: a debounced persist for element A must call the callback that
397
+ // was live when A's edit was SCHEDULED, not whatever the ref holds by
398
+ // the time the timer fires — a "latest" ref would let a stale timer
399
+ // wrongly target a since-selected element B's callback.
400
+ const result = setAttributeLive(COLOR_GRADING_DATA_KEY, value ?? null, applySettled);
401
+ return trackStudioPendingEdit(
402
+ Promise.resolve(result).then(
403
+ () => undefined,
404
+ () => applySettled(false),
405
+ ),
406
+ );
407
+ },
408
+ [],
409
+ );
410
+
411
+ const flushPendingPersist = useCallback(() => {
412
+ if (persistTimerRef.current) {
413
+ clearTimeout(persistTimerRef.current);
414
+ persistTimerRef.current = null;
415
+ }
416
+ if (pendingPersistValueRef.current === undefined) return undefined;
417
+ const value = pendingPersistValueRef.current;
418
+ const attemptedGrading = pendingPersistGradingRef.current ?? latestGradingRef.current;
419
+ // Snapshotted at schedule time, not identityKeyRef.current read fresh
420
+ // here — an async flush could otherwise tag the attempt with whatever
421
+ // identity is "current" by then, not the one this edit was made for.
422
+ const attemptIdentityKey = pendingPersistIdentityRef.current ?? identityKeyRef.current;
423
+ pendingPersistValueRef.current = undefined;
424
+ pendingPersistGradingRef.current = null;
425
+ pendingPersistIdentityRef.current = null;
426
+ // A flush cancels the pending debounce timer above, so this becomes the
427
+ // one-and-only in-flight attempt for this element — bump the version so
428
+ // it still registers as "the latest attempt" against the same guard a
429
+ // regular debounced commit uses, instead of unconditionally claiming
430
+ // that title. Without this, a newer edit landing after the flush starts
431
+ // but before it settles would have its own eventual settle silently
432
+ // lose the version race to this unconditionally-"latest" flush.
433
+ const isLatestAttempt = bumpDomEditCommitVersion(gradingVersionRef);
434
+ return persistColorGradingValue(
435
+ value,
436
+ attemptedGrading,
437
+ attemptIdentityKey,
438
+ isLatestAttempt,
439
+ onSetAttributeLive,
440
+ );
441
+ }, [onSetAttributeLive, persistColorGradingValue]);
442
+
443
+ useEffect(() => addStudioPendingEditFlushListener(flushPendingPersist), [flushPendingPersist]);
444
+
445
+ useEffect(() => {
446
+ return () => {
447
+ clearStatusTimers();
448
+ void flushPendingPersist();
449
+ };
450
+ }, [clearStatusTimers, flushPendingPersist]);
451
+
452
+ const postColorGrading = useCallback(
453
+ (nextGrading: NormalizedHfColorGrading) => {
454
+ postRuntimeControlMessage(previewIframeRef?.current?.contentWindow, "set-color-grading", {
455
+ target,
456
+ grading: toBridgeColorGrading(nextGrading),
457
+ });
458
+ },
459
+ [previewIframeRef, target],
460
+ );
461
+
462
+ const postCompare = useCallback(
463
+ (enabled: boolean) => {
464
+ postRuntimeControlMessage(
465
+ previewIframeRef?.current?.contentWindow,
466
+ "set-color-grading-compare",
467
+ {
468
+ target,
469
+ compare: { enabled, position: 1, lineWidth: 0 },
470
+ },
471
+ );
472
+ },
473
+ [previewIframeRef, target],
474
+ );
475
+
476
+ useEffect(() => {
477
+ const iframe = previewIframeRef?.current;
478
+ if (!iframe) return;
479
+ const refreshAndReplay = () => {
480
+ const nextGrading = latestGradingRef.current;
481
+ const active = isHfColorGradingActive(nextGrading);
482
+ if (active) postColorGrading(nextGrading);
483
+ postCompare(compareEnabledRef.current && active);
484
+ scheduleRuntimeStatusRefresh();
485
+ };
486
+ const onMessage = (event: MessageEvent) => {
487
+ if (event.source !== iframe.contentWindow) return;
488
+ const data = event.data as { source?: unknown; type?: unknown } | null;
489
+ if (data?.source !== "hf-preview" || data.type !== "ready") return;
490
+ if (!acceptStudioRuntimeMessage(data)) return;
491
+ refreshAndReplay();
492
+ };
493
+ iframe.addEventListener("load", refreshAndReplay);
494
+ window.addEventListener("message", onMessage);
495
+ const timer = window.setTimeout(refreshAndReplay, 80);
496
+ return () => {
497
+ iframe.removeEventListener("load", refreshAndReplay);
498
+ window.removeEventListener("message", onMessage);
499
+ window.clearTimeout(timer);
500
+ };
501
+ }, [postColorGrading, postCompare, previewIframeRef, scheduleRuntimeStatusRefresh]);
502
+
503
+ useEffect(
504
+ () => () => {
505
+ postCompare(false);
506
+ },
507
+ [postCompare],
508
+ );
509
+
510
+ const commitColorGrading = useCallback(
511
+ (nextGrading: NormalizedHfColorGrading) => {
512
+ setGrading(nextGrading);
513
+ setRuntimeStatus({ state: "pending", message: "Updating shader" });
514
+ postColorGrading(nextGrading);
515
+ const active = isHfColorGradingActive(nextGrading);
516
+ if (compareEnabledRef.current) {
517
+ postCompare(active);
518
+ if (!active) setCompareEnabled(false);
519
+ }
520
+ scheduleRuntimeStatusRefresh();
521
+ if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
522
+ pendingPersistValueRef.current = isHfColorGradingActive(nextGrading)
523
+ ? serializeHfColorGrading(nextGrading)
524
+ : null;
525
+ pendingPersistGradingRef.current = nextGrading;
526
+ pendingPersistIdentityRef.current = identityKeyRef.current;
527
+ // Captured now (edit time), not read fresh inside the timer — the
528
+ // timer fires 350ms later and may run after selection has already
529
+ // moved on, at which point identityKeyRef.current would no longer
530
+ // describe the element this edit was actually made for.
531
+ const attemptIdentityKey = identityKeyRef.current;
532
+ // Bumps a monotonic version and hands back a checker bound to THIS
533
+ // specific commit — reused from the same primitive the DOM-attribute
534
+ // commit runner uses for the identical same-target-rapid-edits race.
535
+ const isLatestAttempt = bumpDomEditCommitVersion(gradingVersionRef);
536
+ persistTimerRef.current = setTimeout(() => {
537
+ const value = pendingPersistValueRef.current;
538
+ const attemptedGrading = pendingPersistGradingRef.current ?? nextGrading;
539
+ pendingPersistValueRef.current = undefined;
540
+ pendingPersistGradingRef.current = null;
541
+ pendingPersistIdentityRef.current = null;
542
+ persistTimerRef.current = null;
543
+ void persistColorGradingValue(
544
+ value ?? null,
545
+ attemptedGrading,
546
+ attemptIdentityKey,
547
+ isLatestAttempt,
548
+ onSetAttributeLive,
549
+ );
550
+ }, 350);
551
+ },
552
+ [
553
+ onSetAttributeLive,
554
+ persistColorGradingValue,
555
+ postColorGrading,
556
+ postCompare,
557
+ scheduleRuntimeStatusRefresh,
558
+ ],
559
+ );
560
+
561
+ const commitCompare = useCallback(
562
+ (enabled: boolean) => {
563
+ const nextEnabled = enabled && isHfColorGradingActive(grading);
564
+ setCompareEnabled(nextEnabled);
565
+ if (nextEnabled) postColorGrading(grading);
566
+ postCompare(nextEnabled);
567
+ scheduleRuntimeStatusRefresh();
568
+ },
569
+ [grading, postColorGrading, postCompare, scheduleRuntimeStatusRefresh],
570
+ );
571
+
572
+ const applyToScope = useCallback(async () => {
573
+ if (!onApplyScope || applyBusy) return;
574
+ setApplyBusy(true);
575
+ try {
576
+ const value = isHfColorGradingActive(grading) ? serializeHfColorGrading(grading) : null;
577
+ await onApplyScope(applyScope, value);
578
+ } finally {
579
+ setApplyBusy(false);
580
+ }
581
+ }, [applyBusy, applyScope, grading, onApplyScope]);
582
+
583
+ return {
584
+ grading,
585
+ compareEnabled,
586
+ applyScope,
587
+ applyBusy,
588
+ runtimeStatus,
589
+ mediaMetadata,
590
+ commitColorGrading,
591
+ commitCompare,
592
+ setApplyScope,
593
+ applyToScope,
594
+ resetGrading: () => commitColorGrading(defaultColorGrading()),
595
+ };
596
+ }
@@ -6,6 +6,7 @@ import type {
6
6
  VariableValidationIssue,
7
7
  } from "@hyperframes/sdk";
8
8
  import type { EditHistoryKind } from "../../utils/editHistory";
9
+ import type { PublishSdkSession } from "../../utils/sdkCutover";
9
10
  import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
10
11
  import { useDomEditContext } from "../../contexts/DomEditContext";
11
12
  import { useFileManagerContext } from "../../contexts/FileManagerContext";
@@ -32,6 +33,7 @@ function shellSingleQuote(value: string): string {
32
33
 
33
34
  interface VariablesPanelProps {
34
35
  sdkSession: Composition | null;
36
+ publishSdkSession: PublishSdkSession;
35
37
  reloadPreview: () => void;
36
38
  domEditSaveTimestampRef: MutableRefObject<number>;
37
39
  recordEdit: (entry: {
@@ -247,6 +249,7 @@ const EMPTY_STATE = (
247
249
  // fallow-ignore-next-line complexity
248
250
  export const VariablesPanel = memo(function VariablesPanel({
249
251
  sdkSession,
252
+ publishSdkSession,
250
253
  reloadPreview,
251
254
  domEditSaveTimestampRef,
252
255
  recordEdit,
@@ -285,6 +288,7 @@ export const VariablesPanel = memo(function VariablesPanel({
285
288
  recordEdit,
286
289
  reloadPreview,
287
290
  domEditSaveTimestampRef,
291
+ publishSdkSession,
288
292
  });
289
293
 
290
294
  const declarations = useMemo(
@@ -1,5 +1,9 @@
1
- import { describe, expect, it } from "vitest";
2
- import { resolveCompositionPreviewScale, resolveThumbnailSeekTime } from "./CompositionsTab";
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ resolveCompositionPreviewScale,
4
+ resolveThumbnailSeekTime,
5
+ syncIframePlayback,
6
+ } from "./CompositionsTab";
3
7
 
4
8
  describe("resolveCompositionPreviewScale", () => {
5
9
  it("scales a 16:9 stage to fit the composition card", () => {
@@ -50,3 +54,25 @@ describe("resolveThumbnailSeekTime", () => {
50
54
  expect(resolveThumbnailSeekTime(Number.NaN)).toBe(3);
51
55
  });
52
56
  });
57
+
58
+ describe("syncIframePlayback", () => {
59
+ it("mutes a composition-card preview before playing it", () => {
60
+ const calls: string[] = [];
61
+ const postMessage = vi.fn(() => calls.push("mute"));
62
+ const player = {
63
+ play: vi.fn(() => calls.push("play")),
64
+ };
65
+ const iframe = {
66
+ contentWindow: { __player: player, postMessage },
67
+ getRootNode: () => ({}),
68
+ } as unknown as HTMLIFrameElement;
69
+
70
+ expect(syncIframePlayback(iframe, true)).toBe(true);
71
+ expect(postMessage).toHaveBeenCalledWith(
72
+ expect.objectContaining({ action: "set-muted", muted: true }),
73
+ "*",
74
+ );
75
+ expect(calls).toEqual(["mute", "play"]);
76
+ expect(player.play).toHaveBeenCalledOnce();
77
+ });
78
+ });
@@ -1,4 +1,5 @@
1
1
  import { memo, useCallback, useEffect, useRef, useState } from "react";
2
+ import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
2
3
 
3
4
  interface CompositionsTabProps {
4
5
  projectId: string;
@@ -87,12 +88,13 @@ function resolveIframeDuration(iframe: HTMLIFrameElement | null): number | null
87
88
  }
88
89
  }
89
90
 
90
- function syncIframePlayback(iframe: HTMLIFrameElement | null, shouldPlay: boolean): boolean {
91
+ export function syncIframePlayback(iframe: HTMLIFrameElement | null, shouldPlay: boolean): boolean {
91
92
  try {
92
93
  const player = (iframe?.contentWindow as PreviewWindow | null)?.__player;
93
94
  if (!player) return false;
94
95
 
95
96
  if (shouldPlay) {
97
+ setPreviewMediaMuted(iframe, true);
96
98
  player.play?.();
97
99
  return true;
98
100
  }