@hyperframes/studio 0.7.37 → 0.7.39

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 (109) hide show
  1. package/dist/assets/hyperframes-player-BBrKzTGd.js +459 -0
  2. package/dist/assets/index-0P10SwC_.css +1 -0
  3. package/dist/assets/{index-BXYJbIax.js → index-312a3Ceu.js} +1 -1
  4. package/dist/assets/index-B9YvRJz1.js +396 -0
  5. package/dist/assets/{index-Ct-X2wlf.js → index-CQHiecE7.js} +1 -1
  6. package/dist/{chunk-JND3XUJL.js → chunk-RCLGSZ7C.js} +1 -1
  7. package/dist/chunk-RCLGSZ7C.js.map +1 -0
  8. package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-S6YOLVRG.js} +2 -2
  9. package/dist/index.d.ts +48 -10
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +6045 -3507
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +26 -24
  15. package/src/components/AskAgentModal.tsx +23 -6
  16. package/src/components/LintModal.tsx +40 -10
  17. package/src/components/MediaPreview.tsx +36 -1
  18. package/src/components/SaveQueuePausedBanner.tsx +7 -6
  19. package/src/components/StudioErrorBoundary.tsx +19 -31
  20. package/src/components/StudioFeedbackBar.tsx +11 -2
  21. package/src/components/StudioGlobalDragOverlay.tsx +1 -1
  22. package/src/components/StudioHeader.tsx +184 -106
  23. package/src/components/StudioLeftSidebar.tsx +24 -3
  24. package/src/components/StudioOverlays.tsx +34 -8
  25. package/src/components/StudioPreviewArea.tsx +8 -0
  26. package/src/components/StudioRightPanel.tsx +160 -9
  27. package/src/components/StudioSplash.tsx +6 -3
  28. package/src/components/StudioToast.tsx +12 -11
  29. package/src/components/TimelineToolbar.test.tsx +9 -3
  30. package/src/components/TimelineToolbar.tsx +63 -29
  31. package/src/components/editor/DomEditCropHandles.tsx +238 -0
  32. package/src/components/editor/DomEditOverlay.tsx +111 -108
  33. package/src/components/editor/DomEditRotateHandle.tsx +41 -0
  34. package/src/components/editor/PropertyPanel.test.ts +48 -0
  35. package/src/components/editor/PropertyPanel.tsx +27 -47
  36. package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
  37. package/src/components/editor/SnapToolbar.tsx +20 -1
  38. package/src/components/editor/clipPathHelpers.ts +113 -0
  39. package/src/components/editor/colorGradingScopePatch.test.ts +74 -0
  40. package/src/components/editor/colorGradingScopePatch.ts +57 -0
  41. package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
  42. package/src/components/editor/domEditOverlayCrop.ts +115 -0
  43. package/src/components/editor/domEditOverlayGeometry.ts +14 -0
  44. package/src/components/editor/domEditOverlayShape.ts +39 -0
  45. package/src/components/editor/domEditing.test.ts +2 -0
  46. package/src/components/editor/domEditingTypes.ts +3 -0
  47. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  48. package/src/components/editor/manualEditingAvailability.ts +0 -6
  49. package/src/components/editor/marqueeCommit.ts +3 -2
  50. package/src/components/editor/propertyPanelColorGradingControls.tsx +371 -329
  51. package/src/components/editor/propertyPanelColorGradingSection.tsx +274 -55
  52. package/src/components/editor/propertyPanelColorGradingSlider.tsx +275 -0
  53. package/src/components/editor/propertyPanelHelpers.ts +32 -125
  54. package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
  55. package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
  56. package/src/components/editor/propertyPanelTypes.ts +113 -0
  57. package/src/components/editor/snapTargetCollection.ts +2 -3
  58. package/src/components/editor/useDomEditCompositionRect.ts +68 -0
  59. package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
  60. package/src/components/editor/useDomEditOverlayRects.ts +5 -3
  61. package/src/components/nle/CompositionBreadcrumb.tsx +3 -2
  62. package/src/components/nle/NLELayout.tsx +54 -51
  63. package/src/components/nle/NLEPreview.tsx +41 -5
  64. package/src/components/nle/TimelineResizeDivider.tsx +97 -0
  65. package/src/components/nle/usePreviewBlockDrop.ts +31 -7
  66. package/src/components/renders/RenderQueue.tsx +125 -24
  67. package/src/components/renders/RenderQueueItem.tsx +132 -64
  68. package/src/components/renders/useRenderQueue.ts +136 -24
  69. package/src/components/sidebar/AssetsTab.test.ts +87 -0
  70. package/src/components/sidebar/AssetsTab.tsx +113 -15
  71. package/src/components/sidebar/BlocksTab.tsx +2 -1
  72. package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
  73. package/src/components/storyboard/StoryboardFrameFocus.tsx +39 -16
  74. package/src/components/storyboard/StoryboardLoaded.tsx +19 -2
  75. package/src/components/storyboard/StoryboardView.tsx +6 -0
  76. package/src/components/studioColorGradingScope.ts +124 -0
  77. package/src/components/studioMediaJobs.ts +123 -0
  78. package/src/components/ui/Button.tsx +20 -11
  79. package/src/components/ui/HyperframesLoader.tsx +9 -2
  80. package/src/components/ui/SearchInput.tsx +53 -0
  81. package/src/components/ui/Tooltip.tsx +52 -6
  82. package/src/components/ui/VideoFrameThumbnail.tsx +26 -3
  83. package/src/components/ui/useDialogBehavior.ts +83 -0
  84. package/src/contexts/PanelLayoutContext.tsx +3 -0
  85. package/src/contexts/StudioContext.tsx +7 -0
  86. package/src/hooks/domSelectionTestHarness.ts +1 -0
  87. package/src/hooks/useCompositionContentLoader.ts +40 -0
  88. package/src/hooks/useCropMode.ts +91 -0
  89. package/src/hooks/useDomEditCommits.test.tsx +2 -0
  90. package/src/hooks/useEditorSave.ts +14 -0
  91. package/src/hooks/useFileManager.ts +39 -25
  92. package/src/hooks/useFrameCapture.ts +12 -1
  93. package/src/hooks/usePanelLayout.ts +1 -0
  94. package/src/hooks/usePreviewPersistence.ts +24 -15
  95. package/src/hooks/useStudioContextValue.ts +5 -0
  96. package/src/hooks/useToast.ts +66 -13
  97. package/src/player/components/ShortcutsPanel.tsx +9 -0
  98. package/src/player/store/playerStore.ts +14 -0
  99. package/src/styles/studio.css +61 -0
  100. package/src/utils/sdkResolverShadow.test.ts +53 -3
  101. package/src/utils/sdkResolverShadow.ts +41 -1
  102. package/src/utils/studioPendingEdits.test.ts +43 -0
  103. package/src/utils/studioPendingEdits.ts +45 -0
  104. package/src/utils/studioUiPreferences.ts +4 -0
  105. package/dist/assets/hyperframes-player-BGuumSiM.js +0 -459
  106. package/dist/assets/index-DmkOvZns.css +0 -1
  107. package/dist/assets/index-SYSbQyK4.js +0 -396
  108. package/dist/chunk-JND3XUJL.js.map +0 -1
  109. /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-S6YOLVRG.js.map} +0 -0
@@ -9,52 +9,116 @@ import {
9
9
  } from "react";
10
10
  import {
11
11
  HF_COLOR_GRADING_ATTR,
12
- HF_COLOR_GRADING_COLOR_SPACE,
13
12
  isHfColorGradingActive,
14
13
  normalizeHfColorGrading,
15
14
  serializeHfColorGrading,
16
- type HfColorGradingAdjustKey,
17
15
  type HfColorGradingTarget,
18
16
  type NormalizedHfColorGrading,
19
17
  } from "@hyperframes/core/color-grading";
20
18
  import { Compare, Palette, RotateCcw } from "../../icons/SystemIcons";
19
+ import {
20
+ addStudioPendingEditFlushListener,
21
+ trackStudioPendingEdit,
22
+ } from "../../utils/studioPendingEdits";
21
23
  import type { DomEditSelection } from "./domEditing";
22
24
  import { ColorGradingControls } from "./propertyPanelColorGradingControls";
25
+ import { stripQueryAndHash } from "./propertyPanelHelpers";
23
26
  import { Section } from "./propertyPanelPrimitives";
24
27
 
25
- const DEFAULT_ADJUST: Record<HfColorGradingAdjustKey, number> = {
26
- exposure: 0,
27
- contrast: 0,
28
- highlights: 0,
29
- shadows: 0,
30
- whites: 0,
31
- blacks: 0,
32
- temperature: 0,
33
- tint: 0,
34
- saturation: 0,
35
- };
36
-
37
- const DEFAULT_COLOR_GRADING: NormalizedHfColorGrading = {
38
- enabled: true,
39
- preset: "neutral",
40
- intensity: 1,
41
- adjust: DEFAULT_ADJUST,
42
- lut: null,
43
- colorSpace: HF_COLOR_GRADING_COLOR_SPACE,
44
- };
45
-
46
28
  const COLOR_GRADING_DATA_KEY = HF_COLOR_GRADING_ATTR.replace(/^data-/, "");
29
+ const RUNTIME_STATUS_REFRESH_DELAYS = [50, 250, 1000, 2500] as const;
30
+ const MEDIA_METADATA_CACHE = new Map<string, MediaMetadata | null>();
47
31
 
48
32
  interface RuntimeColorGradingStatus {
49
33
  state: "missing" | "inactive" | "pending" | "active" | "unavailable";
50
34
  message: string;
51
35
  }
52
36
 
37
+ interface MediaMetadata {
38
+ kind: "video" | "image" | "audio" | "unknown";
39
+ color: {
40
+ dynamicRange: "hdr" | "sdr" | "unknown";
41
+ hdrTransfer: "pq" | "hlg" | "unknown" | null;
42
+ label: string;
43
+ isHdr: boolean;
44
+ codecName?: string;
45
+ profile?: string;
46
+ pixelFormat?: string;
47
+ colorSpace?: string;
48
+ colorTransfer?: string;
49
+ colorPrimaries?: string;
50
+ };
51
+ probeError?: string;
52
+ }
53
+
54
+ interface MediaMetadataResponse {
55
+ path: string;
56
+ metadata: MediaMetadata;
57
+ }
58
+
59
+ function stripPreviewAssetPath(src: string, projectId: string): string | null {
60
+ let pathname = src;
61
+ try {
62
+ pathname = new URL(src, window.location.href).pathname;
63
+ } catch {
64
+ return null;
65
+ }
66
+ const projectMarker = `/api/projects/${encodeURIComponent(projectId)}/preview/`;
67
+ const genericMarker = "/preview/";
68
+ const marker = pathname.includes(projectMarker) ? projectMarker : genericMarker;
69
+ const index = pathname.indexOf(marker);
70
+ if (index < 0) return null;
71
+ const assetPath = decodeURIComponent(pathname.slice(index + marker.length)).replace(/^\/+/, "");
72
+ if (!assetPath || assetPath.startsWith("comp/")) return null;
73
+ return assetPath;
74
+ }
75
+
76
+ // fallow-ignore-next-line complexity
77
+ function resolveProjectAssetPath(
78
+ sourceFile: string,
79
+ src: string,
80
+ projectId: string,
81
+ ): string | null {
82
+ const trimmed = stripQueryAndHash(src.trim());
83
+ if (!trimmed || /^(?:data:|blob:)/i.test(trimmed)) return null;
84
+ if (/^https?:\/\//i.test(trimmed)) return stripPreviewAssetPath(trimmed, projectId);
85
+ if (trimmed.startsWith("/")) {
86
+ return stripPreviewAssetPath(trimmed, projectId);
87
+ }
88
+
89
+ const sourceDir = sourceFile.includes("/")
90
+ ? sourceFile.slice(0, sourceFile.lastIndexOf("/"))
91
+ : "";
92
+ const parts = `${sourceDir}/${trimmed}`.split("/");
93
+ const normalized: string[] = [];
94
+ for (const part of parts) {
95
+ if (!part || part === ".") continue;
96
+ if (part === "..") {
97
+ normalized.pop();
98
+ continue;
99
+ }
100
+ normalized.push(part);
101
+ }
102
+ return normalized.join("/") || null;
103
+ }
104
+
105
+ function selectedMediaAssetPath(element: DomEditSelection, projectId: string): string | null {
106
+ if (element.tagName !== "video" && element.tagName !== "img") return null;
107
+ const media = element.element as HTMLImageElement | HTMLVideoElement;
108
+ const src = media.getAttribute("src") || media.currentSrc || "";
109
+ return resolveProjectAssetPath(element.sourceFile || "index.html", src, projectId);
110
+ }
111
+
112
+ function defaultColorGrading(): NormalizedHfColorGrading {
113
+ const grading = normalizeHfColorGrading("neutral");
114
+ if (!grading) throw new Error("Missing neutral color grading preset");
115
+ return grading;
116
+ }
117
+
53
118
  function readColorGradingFromElement(element: DomEditSelection): NormalizedHfColorGrading {
54
- const grading =
55
- normalizeHfColorGrading(element.dataAttributes[COLOR_GRADING_DATA_KEY]) ??
56
- DEFAULT_COLOR_GRADING;
57
- return { ...grading, intensity: 1 };
119
+ return (
120
+ normalizeHfColorGrading(element.dataAttributes[COLOR_GRADING_DATA_KEY]) ?? defaultColorGrading()
121
+ );
58
122
  }
59
123
 
60
124
  function toBridgeColorGrading(grading: NormalizedHfColorGrading): unknown {
@@ -97,13 +161,45 @@ function StatusPill({ status }: { status: RuntimeColorGradingStatus }) {
97
161
  ? "bg-red-400"
98
162
  : "bg-panel-text-5";
99
163
  return (
100
- <div className="flex min-w-0 items-center gap-1.5 rounded bg-panel-input px-2 py-1 text-[10px] font-medium text-panel-text-3">
164
+ <div
165
+ className="flex min-w-0 items-center gap-1.5 rounded bg-panel-input px-2 py-1 text-[10px] font-medium text-panel-text-3"
166
+ title={status.message}
167
+ >
101
168
  <span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${dotClass}`} />
102
169
  <span className="truncate">{status.message}</span>
103
170
  </div>
104
171
  );
105
172
  }
106
173
 
174
+ function HdrMediaWarning({ metadata }: { metadata: MediaMetadata | null }) {
175
+ if (metadata?.color.dynamicRange !== "hdr") return null;
176
+ const details = [
177
+ metadata.color.codecName,
178
+ metadata.color.profile,
179
+ metadata.color.pixelFormat,
180
+ metadata.color.colorPrimaries,
181
+ metadata.color.colorTransfer,
182
+ ]
183
+ .filter(Boolean)
184
+ .join(" · ");
185
+
186
+ return (
187
+ <div className="mb-3 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] leading-4 text-amber-100">
188
+ <div className="mb-1 flex min-w-0 items-center justify-between gap-2">
189
+ <span className="font-semibold">{metadata.color.label} source</span>
190
+ <span className="rounded bg-amber-400/20 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-amber-100">
191
+ SDR preview
192
+ </span>
193
+ </div>
194
+ <p className="text-amber-100/80">
195
+ These controls use the current SDR shader preview path. Render may stay HDR-tagged, but this
196
+ is not true HDR color grading yet.
197
+ </p>
198
+ {details && <p className="mt-1 truncate text-[10px] text-amber-100/55">{details}</p>}
199
+ </div>
200
+ );
201
+ }
202
+
107
203
  function HoldBeforeButton({
108
204
  active,
109
205
  disabled,
@@ -170,29 +266,46 @@ function HoldBeforeButton({
170
266
  }
171
267
 
172
268
  export function ColorGradingSection({
269
+ projectId,
173
270
  element,
174
271
  assets,
175
272
  previewIframeRef,
176
273
  onImportAssets,
177
274
  onSetAttributeLive,
275
+ onApplyScope,
178
276
  }: {
277
+ projectId: string;
179
278
  element: DomEditSelection;
180
279
  assets: string[];
181
280
  previewIframeRef?: RefObject<HTMLIFrameElement | null>;
182
281
  onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
183
282
  onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>;
283
+ onApplyScope?: (
284
+ scope: "source-file" | "project",
285
+ value: string | null,
286
+ ) => Promise<{ changedFiles: number; changedElements: number }>;
184
287
  }) {
185
288
  const [grading, setGrading] = useState(() => readColorGradingFromElement(element));
186
289
  const [compareEnabled, setCompareEnabled] = useState(false);
290
+ const [applyScope, setApplyScope] = useState<"source-file" | "project">("source-file");
291
+ const [applyBusy, setApplyBusy] = useState(false);
187
292
  const [runtimeStatus, setRuntimeStatus] = useState<RuntimeColorGradingStatus>(() => ({
188
293
  state: "pending",
189
294
  message: "Waiting for runtime",
190
295
  }));
296
+ const selectedAssetPath = useMemo(
297
+ () => selectedMediaAssetPath(element, projectId),
298
+ [element, projectId],
299
+ );
300
+ const [mediaMetadata, setMediaMetadata] = useState<MediaMetadata | null>(null);
191
301
  const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
192
302
  const pendingPersistValueRef = useRef<string | null | undefined>(undefined);
303
+ const statusTimersRef = useRef<number[]>([]);
193
304
  const onSetAttributeLiveRef = useRef(onSetAttributeLive);
305
+ const latestGradingRef = useRef(grading);
194
306
  const compareEnabledRef = useRef(compareEnabled);
195
307
  onSetAttributeLiveRef.current = onSetAttributeLive;
308
+ latestGradingRef.current = grading;
196
309
  compareEnabledRef.current = compareEnabled;
197
310
  const target = useMemo(
198
311
  (): HfColorGradingTarget => ({
@@ -208,33 +321,75 @@ export function ColorGradingSection({
208
321
  setRuntimeStatus(readRuntimeColorGradingStatus(previewIframeRef?.current, target));
209
322
  }, [previewIframeRef, target]);
210
323
 
324
+ useEffect(() => {
325
+ setMediaMetadata(null);
326
+ if (!selectedAssetPath) return;
327
+ const cacheKey = `${projectId}:${selectedAssetPath}`;
328
+ if (MEDIA_METADATA_CACHE.has(cacheKey)) {
329
+ setMediaMetadata(MEDIA_METADATA_CACHE.get(cacheKey) ?? null);
330
+ return;
331
+ }
332
+ const controller = new AbortController();
333
+ fetch(
334
+ `/api/projects/${encodeURIComponent(projectId)}/media/metadata?path=${encodeURIComponent(
335
+ selectedAssetPath,
336
+ )}`,
337
+ { signal: controller.signal },
338
+ )
339
+ .then((response) => (response.ok ? response.json() : null))
340
+ .then((data: MediaMetadataResponse | null) => {
341
+ if (controller.signal.aborted) return;
342
+ const metadata = data?.metadata ?? null;
343
+ MEDIA_METADATA_CACHE.set(cacheKey, metadata);
344
+ setMediaMetadata(metadata);
345
+ })
346
+ .catch(() => {
347
+ if (!controller.signal.aborted) MEDIA_METADATA_CACHE.set(cacheKey, null);
348
+ });
349
+ return () => controller.abort();
350
+ }, [projectId, selectedAssetPath]);
351
+
352
+ const clearStatusTimers = useCallback(() => {
353
+ for (const timer of statusTimersRef.current) clearTimeout(timer);
354
+ statusTimersRef.current = [];
355
+ }, []);
356
+
357
+ const scheduleRuntimeStatusRefresh = useCallback(() => {
358
+ clearStatusTimers();
359
+ statusTimersRef.current = RUNTIME_STATUS_REFRESH_DELAYS.map((delay) =>
360
+ window.setTimeout(refreshRuntimeStatus, delay),
361
+ );
362
+ }, [clearStatusTimers, refreshRuntimeStatus]);
363
+
211
364
  useEffect(() => {
212
365
  refreshRuntimeStatus();
213
366
  }, [refreshRuntimeStatus]);
214
367
 
215
- useEffect(() => {
216
- const iframe = previewIframeRef?.current;
217
- if (!iframe) return;
218
- const refresh = () => {
219
- window.setTimeout(refreshRuntimeStatus, 50);
220
- };
221
- iframe.addEventListener("load", refresh);
222
- const timer = window.setTimeout(refreshRuntimeStatus, 80);
223
- return () => {
224
- iframe.removeEventListener("load", refresh);
225
- window.clearTimeout(timer);
226
- };
227
- }, [previewIframeRef, refreshRuntimeStatus]);
368
+ const persistColorGradingValue = useCallback((value: string | null) => {
369
+ return trackStudioPendingEdit(
370
+ onSetAttributeLiveRef.current(COLOR_GRADING_DATA_KEY, value ?? null),
371
+ );
372
+ }, []);
373
+
374
+ const flushPendingPersist = useCallback(() => {
375
+ if (persistTimerRef.current) {
376
+ clearTimeout(persistTimerRef.current);
377
+ persistTimerRef.current = null;
378
+ }
379
+ if (pendingPersistValueRef.current === undefined) return undefined;
380
+ const value = pendingPersistValueRef.current;
381
+ pendingPersistValueRef.current = undefined;
382
+ return persistColorGradingValue(value);
383
+ }, [persistColorGradingValue]);
384
+
385
+ useEffect(() => addStudioPendingEditFlushListener(flushPendingPersist), [flushPendingPersist]);
228
386
 
229
387
  useEffect(() => {
230
388
  return () => {
231
- if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
232
- if (pendingPersistValueRef.current !== undefined) {
233
- void onSetAttributeLiveRef.current(COLOR_GRADING_DATA_KEY, pendingPersistValueRef.current);
234
- pendingPersistValueRef.current = undefined;
235
- }
389
+ clearStatusTimers();
390
+ void flushPendingPersist();
236
391
  };
237
- }, []);
392
+ }, [clearStatusTimers, flushPendingPersist]);
238
393
 
239
394
  const postColorGrading = useCallback(
240
395
  (nextGrading: NormalizedHfColorGrading) => {
@@ -272,6 +427,31 @@ export function ColorGradingSection({
272
427
  [previewIframeRef, target],
273
428
  );
274
429
 
430
+ useEffect(() => {
431
+ const iframe = previewIframeRef?.current;
432
+ if (!iframe) return;
433
+ const refreshAndReplay = () => {
434
+ const nextGrading = latestGradingRef.current;
435
+ const active = isHfColorGradingActive(nextGrading);
436
+ if (active) postColorGrading(nextGrading);
437
+ postCompare(compareEnabledRef.current && active);
438
+ scheduleRuntimeStatusRefresh();
439
+ };
440
+ const onMessage = (event: MessageEvent) => {
441
+ if (event.source !== iframe.contentWindow) return;
442
+ const data = event.data as { source?: unknown; type?: unknown } | null;
443
+ if (data?.source === "hf-preview" && data.type === "ready") refreshAndReplay();
444
+ };
445
+ iframe.addEventListener("load", refreshAndReplay);
446
+ window.addEventListener("message", onMessage);
447
+ const timer = window.setTimeout(refreshAndReplay, 80);
448
+ return () => {
449
+ iframe.removeEventListener("load", refreshAndReplay);
450
+ window.removeEventListener("message", onMessage);
451
+ window.clearTimeout(timer);
452
+ };
453
+ }, [postColorGrading, postCompare, previewIframeRef, scheduleRuntimeStatusRefresh]);
454
+
275
455
  useEffect(
276
456
  () => () => {
277
457
  postCompare(false);
@@ -289,7 +469,7 @@ export function ColorGradingSection({
289
469
  postCompare(active);
290
470
  if (!active) setCompareEnabled(false);
291
471
  }
292
- window.setTimeout(refreshRuntimeStatus, 50);
472
+ scheduleRuntimeStatusRefresh();
293
473
  if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
294
474
  pendingPersistValueRef.current = isHfColorGradingActive(nextGrading)
295
475
  ? serializeHfColorGrading(nextGrading)
@@ -297,10 +477,11 @@ export function ColorGradingSection({
297
477
  persistTimerRef.current = setTimeout(() => {
298
478
  const value = pendingPersistValueRef.current;
299
479
  pendingPersistValueRef.current = undefined;
300
- void onSetAttributeLive(COLOR_GRADING_DATA_KEY, value ?? null);
480
+ persistTimerRef.current = null;
481
+ void persistColorGradingValue(value ?? null);
301
482
  }, 350);
302
483
  },
303
- [onSetAttributeLive, postColorGrading, postCompare, refreshRuntimeStatus],
484
+ [persistColorGradingValue, postColorGrading, postCompare, scheduleRuntimeStatusRefresh],
304
485
  );
305
486
 
306
487
  const commitCompare = useCallback(
@@ -309,14 +490,25 @@ export function ColorGradingSection({
309
490
  setCompareEnabled(nextEnabled);
310
491
  if (nextEnabled) postColorGrading(grading);
311
492
  postCompare(nextEnabled);
312
- window.setTimeout(refreshRuntimeStatus, 50);
493
+ scheduleRuntimeStatusRefresh();
313
494
  },
314
- [grading, postColorGrading, postCompare, refreshRuntimeStatus],
495
+ [grading, postColorGrading, postCompare, scheduleRuntimeStatusRefresh],
315
496
  );
316
497
 
498
+ const applyToScope = useCallback(async () => {
499
+ if (!onApplyScope || applyBusy) return;
500
+ setApplyBusy(true);
501
+ try {
502
+ const value = isHfColorGradingActive(grading) ? serializeHfColorGrading(grading) : null;
503
+ await onApplyScope(applyScope, value);
504
+ } finally {
505
+ setApplyBusy(false);
506
+ }
507
+ }, [applyBusy, applyScope, grading, onApplyScope]);
508
+
317
509
  return (
318
510
  <Section
319
- title="Color Grading"
511
+ title="Color grading"
320
512
  icon={<Palette size={15} />}
321
513
  accessory={
322
514
  <div className="flex min-w-0 items-center gap-1.5">
@@ -330,22 +522,49 @@ export function ColorGradingSection({
330
522
  type="button"
331
523
  onClick={(event) => {
332
524
  event.stopPropagation();
333
- commitColorGrading(DEFAULT_COLOR_GRADING);
525
+ commitColorGrading(defaultColorGrading());
334
526
  }}
335
527
  className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1"
336
- title="Reset grading"
528
+ title="Reset color grading"
337
529
  >
338
530
  <RotateCcw size={12} />
339
531
  </button>
340
532
  </div>
341
533
  }
342
534
  >
535
+ <HdrMediaWarning metadata={mediaMetadata} />
343
536
  <ColorGradingControls
344
537
  grading={grading}
345
538
  assets={assets}
346
539
  onImportAssets={onImportAssets}
347
540
  onCommitColorGrading={commitColorGrading}
348
541
  />
542
+ {onApplyScope && (
543
+ <div className="mt-4 grid min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-2">
544
+ <select
545
+ value={applyScope}
546
+ onChange={(event) => setApplyScope(event.currentTarget.value as typeof applyScope)}
547
+ disabled={applyBusy}
548
+ className="w-full min-w-0 rounded-md bg-panel-input px-3 py-2 text-[11px] font-medium text-panel-text-1 outline-none disabled:cursor-not-allowed disabled:opacity-50"
549
+ title="Choose where to copy these color grading settings"
550
+ >
551
+ <option value="source-file">Current file media</option>
552
+ <option value="project">All project media</option>
553
+ </select>
554
+ <button
555
+ type="button"
556
+ disabled={applyBusy}
557
+ onClick={(event) => {
558
+ event.stopPropagation();
559
+ void applyToScope();
560
+ }}
561
+ className="h-8 rounded-md bg-panel-input px-3 text-[11px] font-medium text-panel-text-2 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-50"
562
+ title="Copy these color grading settings to the selected scope"
563
+ >
564
+ {applyBusy ? "Applying" : "Apply"}
565
+ </button>
566
+ </div>
567
+ )}
349
568
  </Section>
350
569
  );
351
570
  }