@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.
- package/dist/assets/{hyperframes-player-CgnoxDoD.js → hyperframes-player-zGIbZhLJ.js} +1 -1
- package/dist/assets/{index-J9HXe7mU.js → index-BiLCTunu.js} +1 -1
- package/dist/assets/{index-CsG24Jub.js → index-DSazIt7R.js} +198 -198
- package/dist/assets/{index-DZGVvqDT.js → index-DStmfnpZ.js} +1 -1
- package/dist/index.d.ts +172 -7
- package/dist/index.html +1 -1
- package/dist/index.js +2567 -2314
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +8 -9
- package/src/components/StudioRightPanels.tsx +4 -48
- package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
- package/src/components/TimelineHistoryButtons.tsx +26 -14
- package/src/components/TimelineToolbar.test.tsx +9 -1
- package/src/components/TimelineToolbar.tsx +13 -5
- package/src/components/editor/BlockParamsPanel.tsx +14 -10
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
- package/src/components/editor/manualEdits.test.ts +24 -0
- package/src/components/editor/manualEdits.ts +6 -2
- package/src/components/editor/manualEditsDom.ts +0 -65
- package/src/components/editor/manualEditsSeekReapply.ts +136 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +71 -32
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
- package/src/components/editor/propertyPanelHelpers.ts +15 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
- package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
- package/src/components/editor/useFxLevelling.ts +35 -10
- package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
- package/src/components/nle/AudioMeterStrip.tsx +23 -12
- package/src/components/panels/VariablesOtherCompositions.tsx +4 -4
- package/src/components/panels/VariablesPanel.test.tsx +86 -0
- package/src/components/panels/VariablesPanel.tsx +3 -3
- package/src/components/pointerTargetSize.test.tsx +1 -1
- package/src/components/sidebar/SidebarLintButton.test.tsx +29 -0
- package/src/components/sidebar/SidebarLintButton.tsx +4 -0
- package/src/components/studioColorGradingScope.ts +16 -42
- package/src/contexts/TimelineEditContext.test.tsx +49 -0
- package/src/contexts/TimelineEditContext.tsx +27 -23
- package/src/hooks/canvasQuickSaves.test.tsx +195 -0
- package/src/hooks/gsapMutationClient.ts +116 -0
- package/src/hooks/resizeObserverTestUtils.ts +14 -0
- package/src/hooks/timelineAudioGroupCreate.ts +18 -40
- package/src/hooks/timelineEditingHelpers.test.ts +21 -0
- package/src/hooks/timelineEditingHelpers.ts +66 -61
- package/src/hooks/timelineQuickSaves.test.ts +195 -0
- package/src/hooks/timelineTimingSync.test.ts +12 -0
- package/src/hooks/timelineTimingSync.ts +31 -123
- package/src/hooks/timelineTrackVisibility.ts +15 -29
- package/src/hooks/useApplyColorGradingScope.test.tsx +53 -0
- package/src/hooks/useApplyColorGradingScope.ts +54 -0
- package/src/hooks/useAskAgentModal.test.tsx +78 -0
- package/src/hooks/useAskAgentModal.ts +3 -1
- package/src/hooks/useClipboard.source.test.ts +32 -0
- package/src/hooks/useClipboard.ts +21 -20
- package/src/hooks/useDomEditCommits.test.tsx +13 -9
- package/src/hooks/useDomEditCommits.ts +88 -97
- package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useElementLifecycleOps.ts +36 -37
- package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
- package/src/hooks/useFileManager.ts +21 -12
- package/src/hooks/useGroupCommits.ts +23 -24
- package/src/hooks/useGsapAnimationOps.ts +26 -8
- package/src/hooks/useGsapScriptCommits.ts +1 -0
- package/src/hooks/useLintModal.test.tsx +15 -0
- package/src/hooks/useLintModal.ts +5 -2
- package/src/hooks/useProjectCompositionVariables.ts +3 -3
- package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
- package/src/hooks/useThumbnailStripSize.ts +31 -0
- package/src/hooks/useTimelineAssetDropOps.ts +38 -35
- package/src/hooks/useTimelineDeleteOps.ts +53 -56
- package/src/hooks/useTimelineEditing.ts +3 -1
- package/src/hooks/useTimelineGroupEditing.ts +5 -1
- package/src/index.ts +8 -0
- package/src/player/components/CompositionThumbnail.test.ts +40 -6
- package/src/player/components/CompositionThumbnail.tsx +6 -21
- package/src/player/components/ImageThumbnail.test.tsx +27 -6
- package/src/player/components/ImageThumbnail.tsx +4 -18
- package/src/player/components/PlayerControls.test.tsx +52 -0
- package/src/player/components/PlayerControls.tsx +2 -1
- package/src/player/components/Timeline.test.ts +28 -3
- package/src/player/components/TimelineCanvas.tsx +5 -3
- package/src/player/components/VideoThumbnail.test.tsx +33 -11
- package/src/player/components/VideoThumbnail.tsx +7 -29
- package/src/player/components/thumbnailUtils.test.ts +16 -20
- package/src/player/components/thumbnailUtils.ts +6 -7
- package/src/player/components/timelineEditing.test.ts +55 -0
- package/src/player/components/timelineGroupEditing.ts +13 -6
- package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
- package/src/player/components/timelinePlayheadTransform.ts +17 -0
- package/src/player/components/useTimelinePlayhead.ts +26 -8
- package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
- package/src/player/hooks/timelinePlayerSync.ts +18 -8
- package/src/player/hooks/useShadowPreviewReload.ts +15 -4
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
- package/src/player/lib/playbackShortcuts.ts +18 -0
- package/src/player/lib/playbackTypes.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +92 -0
- package/src/player/lib/timelineDOM.ts +0 -2
- package/src/player/lib/timelineElementHelpers.ts +47 -10
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/player/store/timelineElement.ts +2 -1
- package/src/timelineChromeExports.test.tsx +79 -0
- package/src/utils/authoredSource.ts +15 -0
- package/src/utils/blockInstaller.ts +32 -27
- package/src/utils/studioFileHistory.test.ts +14 -14
- 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
|
-
|
|
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 {
|
|
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
|
-
*
|
|
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(
|
|
183
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
541
|
-
excludePath={
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
value
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
}
|