@hyperframes/studio 0.8.80 → 0.8.82

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 (110) hide show
  1. package/dist/assets/{hyperframes-player-CgnoxDoD.js → hyperframes-player-zGIbZhLJ.js} +1 -1
  2. package/dist/assets/{index-J9HXe7mU.js → index-BiLCTunu.js} +1 -1
  3. package/dist/assets/{index-CsG24Jub.js → index-DSazIt7R.js} +198 -198
  4. package/dist/assets/{index-DZGVvqDT.js → index-DStmfnpZ.js} +1 -1
  5. package/dist/index.d.ts +172 -7
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +2567 -2314
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/App.tsx +8 -9
  11. package/src/components/StudioRightPanels.tsx +4 -48
  12. package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
  13. package/src/components/TimelineHistoryButtons.tsx +26 -14
  14. package/src/components/TimelineToolbar.test.tsx +9 -1
  15. package/src/components/TimelineToolbar.tsx +13 -5
  16. package/src/components/editor/BlockParamsPanel.tsx +14 -10
  17. package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
  18. package/src/components/editor/manualEdits.test.ts +24 -0
  19. package/src/components/editor/manualEdits.ts +6 -2
  20. package/src/components/editor/manualEditsDom.ts +0 -65
  21. package/src/components/editor/manualEditsSeekReapply.ts +136 -0
  22. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +71 -32
  23. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
  24. package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
  25. package/src/components/editor/propertyPanelHelpers.ts +15 -0
  26. package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
  27. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
  28. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
  29. package/src/components/editor/useFxLevelling.ts +35 -10
  30. package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
  31. package/src/components/nle/AudioMeterStrip.tsx +23 -12
  32. package/src/components/panels/VariablesOtherCompositions.tsx +4 -4
  33. package/src/components/panels/VariablesPanel.test.tsx +86 -0
  34. package/src/components/panels/VariablesPanel.tsx +3 -3
  35. package/src/components/pointerTargetSize.test.tsx +1 -1
  36. package/src/components/sidebar/SidebarLintButton.test.tsx +29 -0
  37. package/src/components/sidebar/SidebarLintButton.tsx +4 -0
  38. package/src/components/studioColorGradingScope.ts +16 -42
  39. package/src/contexts/TimelineEditContext.test.tsx +49 -0
  40. package/src/contexts/TimelineEditContext.tsx +27 -23
  41. package/src/hooks/canvasQuickSaves.test.tsx +195 -0
  42. package/src/hooks/gsapMutationClient.ts +116 -0
  43. package/src/hooks/resizeObserverTestUtils.ts +14 -0
  44. package/src/hooks/timelineAudioGroupCreate.ts +18 -40
  45. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  46. package/src/hooks/timelineEditingHelpers.ts +66 -61
  47. package/src/hooks/timelineQuickSaves.test.ts +195 -0
  48. package/src/hooks/timelineTimingSync.test.ts +12 -0
  49. package/src/hooks/timelineTimingSync.ts +31 -123
  50. package/src/hooks/timelineTrackVisibility.ts +15 -29
  51. package/src/hooks/useApplyColorGradingScope.test.tsx +53 -0
  52. package/src/hooks/useApplyColorGradingScope.ts +54 -0
  53. package/src/hooks/useAskAgentModal.test.tsx +78 -0
  54. package/src/hooks/useAskAgentModal.ts +3 -1
  55. package/src/hooks/useClipboard.source.test.ts +32 -0
  56. package/src/hooks/useClipboard.ts +21 -20
  57. package/src/hooks/useDomEditCommits.test.tsx +13 -9
  58. package/src/hooks/useDomEditCommits.ts +88 -97
  59. package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
  60. package/src/hooks/useEditorSave.ts +1 -1
  61. package/src/hooks/useElementLifecycleOps.ts +36 -37
  62. package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
  63. package/src/hooks/useFileManager.ts +21 -12
  64. package/src/hooks/useGroupCommits.ts +23 -24
  65. package/src/hooks/useGsapAnimationOps.ts +26 -8
  66. package/src/hooks/useGsapScriptCommits.ts +1 -0
  67. package/src/hooks/useLintModal.test.tsx +15 -0
  68. package/src/hooks/useLintModal.ts +5 -2
  69. package/src/hooks/useProjectCompositionVariables.ts +3 -3
  70. package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
  71. package/src/hooks/useThumbnailStripSize.ts +31 -0
  72. package/src/hooks/useTimelineAssetDropOps.ts +38 -35
  73. package/src/hooks/useTimelineDeleteOps.ts +53 -56
  74. package/src/hooks/useTimelineEditing.ts +3 -1
  75. package/src/hooks/useTimelineGroupEditing.ts +5 -1
  76. package/src/index.ts +8 -0
  77. package/src/player/components/CompositionThumbnail.test.ts +40 -6
  78. package/src/player/components/CompositionThumbnail.tsx +6 -21
  79. package/src/player/components/ImageThumbnail.test.tsx +27 -6
  80. package/src/player/components/ImageThumbnail.tsx +4 -18
  81. package/src/player/components/PlayerControls.test.tsx +52 -0
  82. package/src/player/components/PlayerControls.tsx +2 -1
  83. package/src/player/components/Timeline.test.ts +28 -3
  84. package/src/player/components/TimelineCanvas.tsx +5 -3
  85. package/src/player/components/VideoThumbnail.test.tsx +33 -11
  86. package/src/player/components/VideoThumbnail.tsx +7 -29
  87. package/src/player/components/thumbnailUtils.test.ts +16 -20
  88. package/src/player/components/thumbnailUtils.ts +6 -7
  89. package/src/player/components/timelineEditing.test.ts +55 -0
  90. package/src/player/components/timelineGroupEditing.ts +13 -6
  91. package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
  92. package/src/player/components/timelinePlayheadTransform.ts +17 -0
  93. package/src/player/components/useTimelinePlayhead.ts +26 -8
  94. package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
  95. package/src/player/hooks/timelinePlayerSync.ts +18 -8
  96. package/src/player/hooks/useShadowPreviewReload.ts +15 -4
  97. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
  98. package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
  99. package/src/player/lib/playbackShortcuts.ts +18 -0
  100. package/src/player/lib/playbackTypes.ts +1 -0
  101. package/src/player/lib/timelineDOM.test.ts +92 -0
  102. package/src/player/lib/timelineDOM.ts +0 -2
  103. package/src/player/lib/timelineElementHelpers.ts +47 -10
  104. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  105. package/src/player/store/timelineElement.ts +2 -1
  106. package/src/timelineChromeExports.test.tsx +79 -0
  107. package/src/utils/authoredSource.ts +15 -0
  108. package/src/utils/blockInstaller.ts +32 -27
  109. package/src/utils/studioFileHistory.test.ts +14 -14
  110. package/src/utils/studioFileHistory.ts +31 -2
@@ -11,6 +11,7 @@ import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
11
11
  import { SILENT_CHANNEL } from "../../utils/audioMeterMath";
12
12
  import {
13
13
  AudioMeterStrip,
14
+ type AudioMeterStripProps,
14
15
  evictGoneMeterState,
15
16
  followMeterHook,
16
17
  stepAndPaintStrips,
@@ -20,8 +21,9 @@ import {
20
21
 
21
22
  const iframe = { contentWindow: null as unknown };
22
23
  const previewIframeRef = { current: iframe };
24
+ let shell: { previewIframeRef: typeof previewIframeRef } | null = { previewIframeRef };
23
25
  vi.mock("../../contexts/StudioContext", () => ({
24
- useStudioShellContext: () => ({ previewIframeRef }),
26
+ useStudioShellContextOptional: () => shell,
25
27
  }));
26
28
 
27
29
  const onSetAudioGroupAttributeLive = vi.fn();
@@ -88,6 +90,7 @@ const tick = () => {
88
90
  };
89
91
 
90
92
  beforeEach(() => {
93
+ shell = { previewIframeRef };
91
94
  frames = [];
92
95
  vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
93
96
  vi.stubGlobal("cancelAnimationFrame", () => {});
@@ -103,11 +106,11 @@ afterEach(() => {
103
106
  vi.unstubAllGlobals();
104
107
  });
105
108
 
106
- function mount() {
109
+ function mount(props: AudioMeterStripProps = {}) {
107
110
  const host = document.createElement("div");
108
111
  document.body.append(host);
109
112
  const root = createRoot(host);
110
- act(() => root.render(<AudioMeterStrip />));
113
+ act(() => root.render(<AudioMeterStrip {...props} />));
111
114
  roots.push(root);
112
115
  return { host, root };
113
116
  }
@@ -177,6 +180,26 @@ describe("AudioMeterStrip", () => {
177
180
  expect(second.read).toHaveBeenCalled();
178
181
  });
179
182
 
183
+ it("reads a host's preview iframe over the shell's", () => {
184
+ usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
185
+ const shellHook = makeHook();
186
+ setHook(shellHook);
187
+ const hostHook = makeHook();
188
+ const hostIframe = { contentWindow: { __hf: { audioMeter: hostHook } } };
189
+ mount({ previewIframeRef: { current: hostIframe as unknown as HTMLIFrameElement } });
190
+ tick();
191
+ expect(hostHook.start).toHaveBeenCalledTimes(1);
192
+ expect(shellHook.start).not.toHaveBeenCalled();
193
+ });
194
+
195
+ it("mounts with no shell and no preview iframe", () => {
196
+ shell = null;
197
+ usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
198
+ const { host } = mount();
199
+ tick();
200
+ expect(host.querySelector("[data-testid=audio-meter-strip]")).not.toBeNull();
201
+ });
202
+
180
203
  it("keeps authored gain 2 above unity and writes exactly 1 at the midpoint", () => {
181
204
  usePlayerStore.setState({ elements: [clip({ audioGroup: "vo", audioGroupVolume: 2 })] });
182
205
  const { host } = mount();
@@ -20,7 +20,7 @@ import type { StereoLevel } from "@hyperframes/core/runtime/levelTap";
20
20
  import { usePlayerStore } from "../../player";
21
21
  import { clampNumber } from "../../utils/studioHelpers";
22
22
  import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
23
- import { useStudioShellContext } from "../../contexts/StudioContext";
23
+ import { useStudioShellContextOptional } from "../../contexts/StudioContext";
24
24
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
25
25
  import {
26
26
  METER_DB_MARKS,
@@ -107,7 +107,7 @@ function useVolumeHandlers(): {
107
107
 
108
108
  type PreviewWindow = (Window & { __hf?: { audioMeter?: AudioMeterHook } }) | null | undefined;
109
109
 
110
- function readHook(iframe: HTMLIFrameElement | null): AudioMeterHook | null {
110
+ function readHook(iframe: HTMLIFrameElement | null | undefined): AudioMeterHook | null {
111
111
  try {
112
112
  return (iframe?.contentWindow as PreviewWindow)?.__hf?.audioMeter ?? null;
113
113
  } catch {
@@ -123,9 +123,8 @@ function paintPeak(el: HTMLElement | null, peak: number): void {
123
123
  el.style.setProperty("transform", peak >= 1 ? "translateY(1px)" : "none");
124
124
  }
125
125
 
126
- /** The fill is a fixed green/amber/red backdrop; painting only moves the dark
127
- * mask that covers the unlit top portion, so a loud peak lights the real red
128
- * band instead of tinting a flat colour brighter. */
126
+ /** The fill is a fixed green/amber/red backdrop; painting only moves the dark mask that covers the unlit top
127
+ * portion, so a loud peak lights the real red band instead of tinting a flat colour brighter. */
129
128
  function paint(bars: StripBars | undefined, channels: Pair): void {
130
129
  channels.forEach((ch, i) => {
131
130
  bars?.[i]?.mask?.style.setProperty("height", `${(1 - ch.level) * 100}%`);
@@ -179,8 +178,13 @@ export function stepAndPaintStrips(
179
178
  }
180
179
 
181
180
  /** One rAF loop re-reads the hook off the live preview window, so a reloaded iframe is followed. */
182
- function useMeterLoop(strips: Strip[], bars: RefObject<Map<string | null, StripBars>>) {
183
- const { previewIframeRef } = useStudioShellContext();
181
+ function useMeterLoop(
182
+ strips: Strip[],
183
+ bars: RefObject<Map<string | null, StripBars>>,
184
+ iframeRef: RefObject<HTMLIFrameElement | null> | undefined,
185
+ ) {
186
+ const shell = useStudioShellContextOptional();
187
+ const previewIframeRef = iframeRef ?? shell?.previewIframeRef;
184
188
  const stripsRef = useRef(strips);
185
189
  stripsRef.current = strips;
186
190
  useEffect(() => {
@@ -190,7 +194,7 @@ function useMeterLoop(strips: Strip[], bars: RefObject<Map<string | null, StripB
190
194
  const state = new Map<string | null, Pair>();
191
195
  const tick = (now: number) => {
192
196
  raf = requestAnimationFrame(tick);
193
- active = followMeterHook(active, readHook(previewIframeRef.current));
197
+ active = followMeterHook(active, readHook(previewIframeRef?.current));
194
198
  const levels = active?.read();
195
199
  const dt = now - last;
196
200
  last = now;
@@ -395,14 +399,21 @@ function MeterStrip({
395
399
  );
396
400
  }
397
401
 
398
- export const AudioMeterStrip = memo(function AudioMeterStrip() {
402
+ export interface AudioMeterStripProps {
403
+ /** Pass a stable ref (useRef): a new object each render restarts the meter loop. */
404
+ previewIframeRef?: RefObject<HTMLIFrameElement | null>;
405
+ }
406
+
407
+ export const AudioMeterStrip = memo(function AudioMeterStrip({
408
+ previewIframeRef,
409
+ }: AudioMeterStripProps) {
399
410
  const visible = useAudioMetersVisible((s) => s.visible);
400
411
  const projectHasAudio = useProjectHasAudio();
401
412
  if (!visible || !projectHasAudio) return null;
402
- return <MeterStripBody />;
413
+ return <MeterStripBody previewIframeRef={previewIframeRef} />;
403
414
  });
404
415
 
405
- function MeterStripBody() {
416
+ function MeterStripBody({ previewIframeRef }: AudioMeterStripProps) {
406
417
  const strips = useStrips();
407
418
  const bars = useRef(new Map<string | null, StripBars>());
408
419
  const register = useRef((id: string | null, b: StripBars | null) => {
@@ -410,7 +421,7 @@ function MeterStripBody() {
410
421
  else bars.current.delete(id);
411
422
  }).current;
412
423
  const { onLive, onCommit } = useVolumeHandlers();
413
- useMeterLoop(strips, bars);
424
+ useMeterLoop(strips, bars, previewIframeRef);
414
425
  return (
415
426
  <div
416
427
  data-testid="audio-meter-strip"
@@ -85,7 +85,7 @@ function CompositionSection({
85
85
  }
86
86
 
87
87
  export function VariablesOtherCompositions({
88
- fileTree,
88
+ compositionPaths,
89
89
  excludePath,
90
90
  refreshKey,
91
91
  readProjectFile,
@@ -93,8 +93,8 @@ export function VariablesOtherCompositions({
93
93
  recordEdit,
94
94
  reloadPreview,
95
95
  }: {
96
- fileTree: string[];
97
- excludePath: string;
96
+ compositionPaths: string[];
97
+ excludePath: string | null;
98
98
  refreshKey: unknown;
99
99
  readProjectFile: (path: string) => Promise<string>;
100
100
  writeProjectFile: (path: string, content: string) => Promise<void>;
@@ -103,7 +103,7 @@ export function VariablesOtherCompositions({
103
103
  }) {
104
104
  const [selfRefresh, setSelfRefresh] = useState(0);
105
105
  const groups = useProjectCompositionVariables(
106
- fileTree,
106
+ compositionPaths,
107
107
  excludePath,
108
108
  readProjectFile,
109
109
  `${refreshKey}:${selfRefresh}`,
@@ -0,0 +1,86 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { openComposition } from "@hyperframes/sdk";
7
+ import { VariablesPanel } from "./VariablesPanel";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ const withTitle = (id: string, label: string) => `<!DOCTYPE html>
12
+ <html data-composition-variables='[{"id":"title","type":"string","label":"${label}","default":"Hello"}]'>
13
+ <body><div data-composition-id="${id}" data-hf-id="hf-stage" data-hf-root data-duration="5"></div></body>
14
+ </html>`;
15
+
16
+ const files: Record<string, string> = {
17
+ "index.html":
18
+ '<html><body><div data-hf-id="hf-stage" data-hf-root data-duration="5"></div></body></html>',
19
+ "main.html": withTitle("main", "Main title"),
20
+ "scene.html": withTitle("scene", "Scene title"),
21
+ ".hyperframes/preview/4884df6e.html": withTitle("root", "Generated title"),
22
+ };
23
+ const shell = {
24
+ activeCompPath: "index.html" as string | null,
25
+ compositions: ["index.html", "scene.html"],
26
+ };
27
+
28
+ vi.mock("../../contexts/StudioContext", () => ({
29
+ useStudioShellContext: () => ({ activeCompPath: shell.activeCompPath, showToast: vi.fn() }),
30
+ useStudioPlaybackContext: () => ({ refreshKey: 0 }),
31
+ }));
32
+ vi.mock("../../contexts/DomEditContext", () => ({
33
+ useDomEditContext: () => ({ domEditSelection: null }),
34
+ }));
35
+ vi.mock("../../contexts/FileManagerContext", () => ({
36
+ useFileManagerContext: () => ({
37
+ readProjectFile: async (path: string) => files[path] ?? "",
38
+ writeProjectFile: vi.fn(),
39
+ fileTree: Object.keys(files),
40
+ compositions: shell.compositions,
41
+ }),
42
+ }));
43
+
44
+ let root: Root | null = null;
45
+
46
+ afterEach(() => {
47
+ act(() => root?.unmount());
48
+ root = null;
49
+ document.body.innerHTML = "";
50
+ });
51
+
52
+ async function renderOtherCompositions(sessionPath: string): Promise<string> {
53
+ const host = document.createElement("div");
54
+ document.body.append(host);
55
+ root = createRoot(host);
56
+ const session = await openComposition(files[sessionPath]!, { history: false });
57
+ await act(async () => {
58
+ root?.render(
59
+ <VariablesPanel
60
+ sdkSession={session}
61
+ publishSdkSession={vi.fn()}
62
+ reloadPreview={vi.fn()}
63
+ recordEdit={vi.fn(async () => {})}
64
+ />,
65
+ );
66
+ });
67
+ const other = () =>
68
+ [...host.querySelectorAll("p")].find((p) => p.textContent === "Other compositions")
69
+ ?.parentElement?.textContent ?? "";
70
+ await vi.waitFor(() => expect(other()).toContain("scene.html"));
71
+ return other();
72
+ }
73
+
74
+ describe("VariablesPanel other compositions", () => {
75
+ it("lists the project's compositions, not Studio's generated preview documents", async () => {
76
+ shell.activeCompPath = "index.html";
77
+ shell.compositions = ["index.html", "scene.html"];
78
+ expect(await renderOtherCompositions("index.html")).not.toContain(".hyperframes/preview");
79
+ });
80
+
81
+ it("leaves out the main composition in the master view when it is not index.html", async () => {
82
+ shell.activeCompPath = null;
83
+ shell.compositions = ["main.html", "scene.html"];
84
+ expect(await renderOtherCompositions("main.html")).not.toContain("main.html");
85
+ });
86
+ });
@@ -254,7 +254,7 @@ export const VariablesPanel = memo(function VariablesPanel({
254
254
  }: VariablesPanelProps) {
255
255
  const { activeCompPath, showToast } = useStudioShellContext();
256
256
  const { refreshKey } = useStudioPlaybackContext();
257
- const { readProjectFile, writeProjectFile, fileTree, compositions } = useFileManagerContext();
257
+ const { readProjectFile, writeProjectFile, compositions } = useFileManagerContext();
258
258
  const { domEditSelection } = useDomEditContext();
259
259
  // Master view (no activeCompPath) targets the real main composition, not a
260
260
  // hardcoded index.html — used for both the persist write target and the
@@ -537,8 +537,8 @@ export const VariablesPanel = memo(function VariablesPanel({
537
537
  </button>
538
538
  )}
539
539
  <VariablesOtherCompositions
540
- fileTree={fileTree}
541
- excludePath={activeCompPath ?? "index.html"}
540
+ compositionPaths={compositions}
541
+ excludePath={effectiveCompPath}
542
542
  refreshKey={`${refreshKey}:${revision}`}
543
543
  readProjectFile={readProjectFile}
544
544
  writeProjectFile={writeProjectFile}
@@ -7,7 +7,7 @@ import { CompositionsTab } from "./sidebar/CompositionsTab";
7
7
  import { TimelineToolbar } from "./TimelineToolbar";
8
8
 
9
9
  vi.mock("../contexts/StudioContext", () => ({
10
- useStudioShellContext: () => ({
10
+ useStudioShellContextOptional: () => ({
11
11
  editHistory: { canUndo: false, canRedo: false },
12
12
  handleUndo: vi.fn(),
13
13
  handleRedo: vi.fn(),
@@ -44,6 +44,35 @@ describe("SidebarLintButton badge", () => {
44
44
  expect(badge?.textContent).toContain("including errors");
45
45
  });
46
46
 
47
+ it("pulses a few times and then holds still, so an idle Studio paints nothing", () => {
48
+ const badge = renderBadge(true);
49
+ expect(badge?.style.animationIterationCount).toBe("3");
50
+ });
51
+
52
+ it("pulses again when the finding count changes", () => {
53
+ const host = document.createElement("div");
54
+ document.body.append(host);
55
+ root = createRoot(host);
56
+ const render = (findingCount: number) =>
57
+ act(() => {
58
+ root?.render(
59
+ <SidebarLintButton
60
+ onLint={vi.fn()}
61
+ linting={false}
62
+ findingCount={findingCount}
63
+ hasError
64
+ />,
65
+ );
66
+ });
67
+ render(2);
68
+ const first = host.querySelector("[data-lint-badge]");
69
+ render(2);
70
+ expect(host.querySelector("[data-lint-badge]")).toBe(first);
71
+ render(3);
72
+ // A new element restarts its CSS animation; a text change on the old one would not.
73
+ expect(host.querySelector("[data-lint-badge]")).not.toBe(first);
74
+ });
75
+
47
76
  it("stays still when the findings are warnings only", () => {
48
77
  const badge = renderBadge(false);
49
78
  expect(badge?.dataset.lintBadge).toBe("warning");
@@ -1,3 +1,5 @@
1
+ const ERROR_PULSES_BEFORE_IDLE = 3;
2
+
1
3
  export function SidebarLintButton({
2
4
  onLint,
3
5
  linting,
@@ -31,7 +33,9 @@ export function SidebarLintButton({
31
33
  {linting ? "Linting…" : "Lint"}
32
34
  {!linting && findingCount != null && findingCount > 0 && (
33
35
  <span
36
+ key={findingCount}
34
37
  data-lint-badge={hasError ? "error" : "warning"}
38
+ style={hasError ? { animationIterationCount: ERROR_PULSES_BEFORE_IDLE } : undefined}
35
39
  className={
36
40
  hasError
37
41
  ? "ml-1 min-w-[16px] rounded-full bg-panel-danger/25 px-1 text-[9px] font-bold text-panel-danger animate-pulse motion-reduce:animate-none"
@@ -22,7 +22,7 @@ interface ApplyColorGradingScopeOptions {
22
22
  scope: ColorGradingScope;
23
23
  value: string | null;
24
24
  selectedSourceFile: string;
25
- fileTree: string[];
25
+ compositionPaths: string[];
26
26
  projectId: string;
27
27
  waitForPendingDomEditSaves: () => Promise<void>;
28
28
  readProjectFile: ProjectFileReader;
@@ -35,36 +35,9 @@ interface ApplyColorGradingScopeOptions {
35
35
  function colorGradingScopePaths(
36
36
  scope: ColorGradingScope,
37
37
  selectedSourceFile: string,
38
- fileTree: string[],
38
+ compositionPaths: string[],
39
39
  ): string[] {
40
- return scope === "source-file"
41
- ? [selectedSourceFile]
42
- : fileTree.filter((path) => /\.html?$/i.test(path));
43
- }
44
-
45
- async function patchColorGradingScopeFiles(
46
- paths: string[],
47
- value: string | null,
48
- readProjectFile: ProjectFileReader,
49
- ): Promise<{ files: Record<string, string>; changedElements: number }> {
50
- const snapshots = await Promise.all(
51
- Array.from(new Set(paths)).map(async (path) => ({
52
- path,
53
- before: await readProjectFile(path),
54
- })),
55
- );
56
- const files: Record<string, string> = {};
57
- let changedElements = 0;
58
-
59
- for (const { path, before } of snapshots) {
60
- const result = patchMediaColorGradingInHtml(before, value);
61
- if (result.html !== before) {
62
- files[path] = result.html;
63
- changedElements += result.count;
64
- }
65
- }
66
-
67
- return { files, changedElements };
40
+ return scope === "source-file" ? [selectedSourceFile] : compositionPaths;
68
41
  }
69
42
 
70
43
  // fallow-ignore-next-line complexity
@@ -72,7 +45,7 @@ export async function applyColorGradingScopeUpdate({
72
45
  scope,
73
46
  value,
74
47
  selectedSourceFile,
75
- fileTree,
48
+ compositionPaths,
76
49
  projectId,
77
50
  waitForPendingDomEditSaves,
78
51
  readProjectFile,
@@ -90,24 +63,25 @@ export async function applyColorGradingScopeUpdate({
90
63
  return EMPTY_COLOR_GRADING_SCOPE_RESULT;
91
64
  }
92
65
 
93
- const { files, changedElements } = await patchColorGradingScopeFiles(
94
- colorGradingScopePaths(scope, selectedSourceFile, fileTree),
95
- value,
96
- readProjectFile,
97
- );
98
- if (Object.keys(files).length === 0) {
99
- showToast("No color grading changed", "info");
100
- return EMPTY_COLOR_GRADING_SCOPE_RESULT;
101
- }
102
-
66
+ let changedElements = 0;
67
+ const patchGrading = (before: string) => {
68
+ const result = patchMediaColorGradingInHtml(before, value);
69
+ changedElements += result.count;
70
+ return result.html;
71
+ };
72
+ const paths = colorGradingScopePaths(scope, selectedSourceFile, compositionPaths);
103
73
  const changedPaths = await saveProjectFilesWithHistory({
104
74
  projectId,
105
75
  label: value ? "Apply color grading" : "Clear color grading",
106
- files,
76
+ files: Object.fromEntries(paths.map((path) => [path, patchGrading])),
107
77
  readFile: readProjectFile,
108
78
  writeFile: writeProjectFile,
109
79
  recordEdit,
110
80
  });
81
+ if (changedPaths.length === 0) {
82
+ showToast("No color grading changed", "info");
83
+ return EMPTY_COLOR_GRADING_SCOPE_RESULT;
84
+ }
111
85
  reloadPreview();
112
86
  showToast(
113
87
  `${value ? "Applied" : "Cleared"} color grading on ${changedElements} media item${changedElements === 1 ? "" : "s"}`,
@@ -0,0 +1,49 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import type { TimelineEditCallbacks } from "../player/components/timelineCallbacks";
6
+ import { TimelineEditProvider, useTimelineEditContext } from "./TimelineEditContext";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ function mountProvider() {
11
+ const seen: TimelineEditCallbacks[] = [];
12
+ function Consumer() {
13
+ seen.push(useTimelineEditContext());
14
+ return null;
15
+ }
16
+ const root = createRoot(document.createElement("div"));
17
+ const render = (value: TimelineEditCallbacks) =>
18
+ act(() =>
19
+ root.render(
20
+ <TimelineEditProvider value={value}>
21
+ <Consumer />
22
+ </TimelineEditProvider>,
23
+ ),
24
+ );
25
+ return { seen, render, unmount: () => act(() => root.unmount()) };
26
+ }
27
+
28
+ describe("TimelineEditProvider", () => {
29
+ it("hands consumers the latest multi-clip resize callback", () => {
30
+ const { seen, render, unmount } = mountProvider();
31
+ const onMoveElement = vi.fn();
32
+ const staleResize = vi.fn();
33
+ const currentResize = vi.fn();
34
+ render({ onMoveElement, onResizeElements: staleResize });
35
+ render({ onMoveElement, onResizeElements: currentResize });
36
+ expect(seen.at(-1)!.onResizeElements).toBe(currentResize);
37
+ unmount();
38
+ });
39
+
40
+ it("keeps the same bag when the parent passes the same callbacks in a new object", () => {
41
+ const { seen, render, unmount } = mountProvider();
42
+ const onMoveElement = vi.fn();
43
+ const onResizeElements = vi.fn();
44
+ render({ onMoveElement, onResizeElements });
45
+ render({ onMoveElement, onResizeElements });
46
+ expect(seen.at(-1)).toBe(seen[0]);
47
+ unmount();
48
+ });
49
+ });
@@ -25,6 +25,32 @@ export function useTimelineEditContextValue(): TimelineEditCallbacks | null {
25
25
  return useContext(TimelineEditContext);
26
26
  }
27
27
 
28
+ const EDIT_CALLBACK_KEY_SET: Record<keyof TimelineEditCallbacks, true> = {
29
+ onMoveElement: true,
30
+ onMoveElements: true,
31
+ onResizeElement: true,
32
+ onResizeElements: true,
33
+ onToggleTrackHidden: true,
34
+ onSetAudioGroupAttributeLive: true,
35
+ onSetAudioGroupAttributeQuiet: true,
36
+ onRevertAudioGroupAttributeLive: true,
37
+ onGroupClips: true,
38
+ onSetElementAttributeLive: true,
39
+ onSetElementAttributeQuiet: true,
40
+ onRevertElementAttributeLive: true,
41
+ onBlockedEditAttempt: true,
42
+ onSplitElement: true,
43
+ onRazorSplit: true,
44
+ onRazorSplitAll: true,
45
+ onDeleteKeyframe: true,
46
+ onDeleteAllKeyframes: true,
47
+ onMoveKeyframeToPlayhead: true,
48
+ onMoveKeyframe: true,
49
+ onToggleKeyframeAtPlayhead: true,
50
+ onTogglePropertyGroupKeyframe: true,
51
+ };
52
+ const EDIT_CALLBACK_KEYS = Object.keys(EDIT_CALLBACK_KEY_SET) as (keyof TimelineEditCallbacks)[];
53
+
28
54
  export function TimelineEditProvider({
29
55
  value,
30
56
  children,
@@ -37,29 +63,7 @@ export function TimelineEditProvider({
37
63
  // Each callback is a stable reference from the parent — memoize the bag
38
64
  // so consumers don't re-render when unrelated parent state changes.
39
65
  // eslint-disable-next-line react-hooks/exhaustive-deps
40
- [
41
- value.onMoveElement,
42
- value.onMoveElements,
43
- value.onResizeElement,
44
- value.onToggleTrackHidden,
45
- value.onSetAudioGroupAttributeLive,
46
- value.onSetAudioGroupAttributeQuiet,
47
- value.onRevertAudioGroupAttributeLive,
48
- value.onGroupClips,
49
- value.onSetElementAttributeLive,
50
- value.onSetElementAttributeQuiet,
51
- value.onRevertElementAttributeLive,
52
- value.onBlockedEditAttempt,
53
- value.onSplitElement,
54
- value.onRazorSplit,
55
- value.onRazorSplitAll,
56
- value.onDeleteKeyframe,
57
- value.onDeleteAllKeyframes,
58
- value.onMoveKeyframeToPlayhead,
59
- value.onMoveKeyframe,
60
- value.onToggleKeyframeAtPlayhead,
61
- value.onTogglePropertyGroupKeyframe,
62
- ],
66
+ EDIT_CALLBACK_KEYS.map((key) => value[key]),
63
67
  );
64
68
  return <TimelineEditContext.Provider value={memoized}>{children}</TimelineEditContext.Provider>;
65
69
  }