@hyperframes/studio 0.8.190 → 0.8.191
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-0HXJPmYC.js → hyperframes-player-Bv9hQ3v7.js} +1 -1
- package/dist/assets/index-CBO8uz7-.css +1 -0
- package/dist/assets/{index-BYw97F07.js → index-D_QqfWhx.js} +124 -123
- package/dist/assets/{index-DsSfhjUS.js → index-Df14bh-L.js} +1 -1
- package/dist/{chunk-563JPIL4.js → chunk-IWO4UENE.js} +8 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.html +2 -2
- package/dist/index.js +457 -257
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/captions/parser.dom.test.ts +126 -0
- package/src/captions/parser.ts +45 -23
- package/src/components/StudioRightPanels.tsx +9 -0
- package/src/components/dock/DockTab.tsx +2 -0
- package/src/components/dock/dockAccessibility.test.ts +3 -3
- package/src/components/dock/dockLayout.ts +1 -1
- package/src/components/dock/panelRegistry.ts +6 -0
- package/src/components/panels/MotionBlurPanel.test.tsx +177 -0
- package/src/components/panels/MotionBlurPanel.tsx +163 -0
- package/src/components/panels/motionBlurPanelModel.test.ts +143 -0
- package/src/components/panels/motionBlurPanelModel.ts +98 -0
- package/src/hooks/usePanelLayout.test.ts +1 -1
- package/src/player/components/TimelineClip.filmstrip.test.tsx +1 -1
- package/src/player/components/timelineMotionStyles.test.ts +1 -1
- package/src/styles/theme.css +1 -1
- package/src/utils/studioHelpers.ts +2 -1
- package/src/utils/studioUrlState.ts +8 -1
- package/dist/assets/index-B3vk3hfU.css +0 -1
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
|
2
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
3
|
+
import type { MotionBlurSettings } from "@hyperframes/core/motion-blur-settings";
|
|
4
|
+
import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
|
|
5
|
+
import { useFileManagerContext } from "../../contexts/FileManagerContext";
|
|
6
|
+
import {
|
|
7
|
+
useVariablesPersist,
|
|
8
|
+
type UseVariablesPersistParams,
|
|
9
|
+
} from "../../hooks/useVariablesPersist";
|
|
10
|
+
import { resolveMasterCompositionPath } from "../../utils/studioUrlState";
|
|
11
|
+
import { Button } from "../ui";
|
|
12
|
+
import {
|
|
13
|
+
BLUR_LEVELS,
|
|
14
|
+
changedEdits,
|
|
15
|
+
findCompositionRoot,
|
|
16
|
+
levelOf,
|
|
17
|
+
patchForLevel,
|
|
18
|
+
readPreviewRootAttributes,
|
|
19
|
+
sameAttributes,
|
|
20
|
+
settingsOf,
|
|
21
|
+
withEdits,
|
|
22
|
+
type BlurLevel,
|
|
23
|
+
type RootAttributes,
|
|
24
|
+
} from "./motionBlurPanelModel";
|
|
25
|
+
|
|
26
|
+
export interface MotionBlurPanelProps {
|
|
27
|
+
sdkSession: Composition | null;
|
|
28
|
+
publishSdkSession?: UseVariablesPersistParams["publishSdkSession"];
|
|
29
|
+
reloadPreview: () => void;
|
|
30
|
+
recordEdit: UseVariablesPersistParams["recordEdit"];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const POLL_MS = 500;
|
|
34
|
+
/** After a write the preview is reloading: keep what was just written instead of the old document. */
|
|
35
|
+
const SETTLE_MS = 1500;
|
|
36
|
+
|
|
37
|
+
/** Thrown from the edit when the file already holds the level, so nothing is written or recorded for undo. */
|
|
38
|
+
class NothingToChange extends Error {}
|
|
39
|
+
|
|
40
|
+
/** The preview's own root attributes, polled; `hold` pins an optimistic value until the preview catches up. */
|
|
41
|
+
function useLivePreviewAttributes(
|
|
42
|
+
previewIframeRef: { current: HTMLIFrameElement | null },
|
|
43
|
+
refreshKey: number,
|
|
44
|
+
) {
|
|
45
|
+
const [live, setLive] = useState<RootAttributes | null>(null);
|
|
46
|
+
const holdUntil = useRef(0);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const read = () => {
|
|
49
|
+
const found = readPreviewRootAttributes(previewIframeRef.current);
|
|
50
|
+
if (found && Date.now() >= holdUntil.current) {
|
|
51
|
+
setLive((current) => (sameAttributes(current, found) ? current : found));
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
read();
|
|
55
|
+
const timer = setInterval(read, POLL_MS);
|
|
56
|
+
return () => clearInterval(timer);
|
|
57
|
+
}, [previewIframeRef, refreshKey]);
|
|
58
|
+
const hold = useCallback((next: (current: RootAttributes | null) => RootAttributes) => {
|
|
59
|
+
holdUntil.current = Date.now() + SETTLE_MS;
|
|
60
|
+
setLive(next);
|
|
61
|
+
}, []);
|
|
62
|
+
return [live, hold] as const;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const MotionBlurPanel = memo(function MotionBlurPanel({
|
|
66
|
+
sdkSession,
|
|
67
|
+
publishSdkSession,
|
|
68
|
+
reloadPreview,
|
|
69
|
+
recordEdit,
|
|
70
|
+
}: MotionBlurPanelProps) {
|
|
71
|
+
const { activeCompPath, previewIframeRef, showToast } = useStudioShellContext();
|
|
72
|
+
const { refreshKey } = useStudioPlaybackContext();
|
|
73
|
+
const { readProjectFile, writeProjectFile, compositions } = useFileManagerContext();
|
|
74
|
+
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
|
75
|
+
const [live, hold] = useLivePreviewAttributes(previewIframeRef, refreshKey);
|
|
76
|
+
|
|
77
|
+
useEffect(() => sdkSession?.on("change", rerender), [sdkSession]);
|
|
78
|
+
|
|
79
|
+
const root = sdkSession ? findCompositionRoot(sdkSession.getRootElements()) : null;
|
|
80
|
+
// The preview's own root is the truth after an undo or an outside edit, which the shared session does not follow.
|
|
81
|
+
const attributes = live ?? root?.attributes ?? null;
|
|
82
|
+
const settings = useMemo(() => (attributes ? settingsOf(attributes) : null), [attributes]);
|
|
83
|
+
const isMain =
|
|
84
|
+
activeCompPath == null || activeCompPath === resolveMasterCompositionPath(compositions);
|
|
85
|
+
const persist = useVariablesPersist({
|
|
86
|
+
sdkSession,
|
|
87
|
+
activeCompPath,
|
|
88
|
+
readProjectFile,
|
|
89
|
+
writeProjectFile,
|
|
90
|
+
recordEdit,
|
|
91
|
+
reloadPreview,
|
|
92
|
+
publishSdkSession,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const apply = useCallback(
|
|
96
|
+
async (patch: Partial<MotionBlurSettings>) => {
|
|
97
|
+
if (!attributes) return;
|
|
98
|
+
// The write lands on the file's own root, so the edits are worked out there, not from the preview.
|
|
99
|
+
let applied: Array<{ name: string; value: string | null }> = [];
|
|
100
|
+
try {
|
|
101
|
+
await persist("Edit motion blur", (session) => {
|
|
102
|
+
const target = findCompositionRoot(session.getRootElements());
|
|
103
|
+
if (!target) return;
|
|
104
|
+
const next = { ...settingsOf(target.attributes), ...patch };
|
|
105
|
+
applied = changedEdits(target.attributes, next);
|
|
106
|
+
if (applied.length === 0) throw new NothingToChange();
|
|
107
|
+
for (const e of applied) {
|
|
108
|
+
session.dispatch({
|
|
109
|
+
type: "setAttribute",
|
|
110
|
+
target: target.id,
|
|
111
|
+
name: e.name,
|
|
112
|
+
value: e.value,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
hold((current) => withEdits(current ?? attributes, applied));
|
|
117
|
+
rerender();
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (err instanceof NothingToChange) return;
|
|
120
|
+
showToast(err instanceof Error ? err.message : String(err), "error");
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
[attributes, persist, hold, showToast],
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
if (!settings) {
|
|
127
|
+
return (
|
|
128
|
+
<p className="p-3 text-[10px] leading-relaxed text-neutral-500">
|
|
129
|
+
Open a composition to set up motion blur.
|
|
130
|
+
</p>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const locked = !isMain;
|
|
134
|
+
// A sub-composition's own root is not the setting being edited, so no level is shown while locked.
|
|
135
|
+
const current = locked ? null : levelOf(settings);
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div className="flex h-full flex-col gap-3 overflow-y-auto p-3 text-[11px] text-neutral-300">
|
|
139
|
+
<span className="text-[12px] font-medium text-neutral-100">Motion blur</span>
|
|
140
|
+
<div role="radiogroup" aria-label="Motion blur" className="flex gap-1.5">
|
|
141
|
+
{BLUR_LEVELS.map(({ level, label }) => (
|
|
142
|
+
<Button
|
|
143
|
+
key={level}
|
|
144
|
+
role="radio"
|
|
145
|
+
aria-checked={current === level}
|
|
146
|
+
size="sm"
|
|
147
|
+
variant={current === level ? "secondary" : "ghost"}
|
|
148
|
+
className="flex-1"
|
|
149
|
+
disabled={locked}
|
|
150
|
+
onClick={() => void apply(patchForLevel(level as BlurLevel))}
|
|
151
|
+
>
|
|
152
|
+
{label}
|
|
153
|
+
</Button>
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
156
|
+
{locked ? (
|
|
157
|
+
<p className="text-[10px] leading-relaxed text-amber-400/90">
|
|
158
|
+
Motion blur is read from the main composition. Open it to change this setting.
|
|
159
|
+
</p>
|
|
160
|
+
) : null}
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { openComposition } from "@hyperframes/sdk";
|
|
5
|
+
import {
|
|
6
|
+
changedEdits,
|
|
7
|
+
findCompositionRoot,
|
|
8
|
+
readPreviewRootAttributes,
|
|
9
|
+
sameAttributes,
|
|
10
|
+
levelOf,
|
|
11
|
+
patchForLevel,
|
|
12
|
+
settingsOf,
|
|
13
|
+
withEdits,
|
|
14
|
+
} from "./motionBlurPanelModel";
|
|
15
|
+
|
|
16
|
+
const html = (attrs = "") =>
|
|
17
|
+
`<!DOCTYPE html><html><body><div data-composition-id="main" data-hf-root data-duration="5" data-width="1920" data-height="1080"${attrs}></div></body></html>`;
|
|
18
|
+
|
|
19
|
+
async function rootOf(attrs?: string) {
|
|
20
|
+
const session = await openComposition(html(attrs), { history: false });
|
|
21
|
+
const root = findCompositionRoot(session.getRootElements());
|
|
22
|
+
if (!root) throw new Error("no root");
|
|
23
|
+
return root;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("findCompositionRoot and settingsOf", () => {
|
|
27
|
+
it("finds the composition root and reads its settings", async () => {
|
|
28
|
+
const root = await rootOf(' data-motion-blur="on" data-motion-blur-shutter="360"');
|
|
29
|
+
expect(settingsOf(root.attributes)).toMatchObject({
|
|
30
|
+
enabled: true,
|
|
31
|
+
shutterDeg: 360,
|
|
32
|
+
tier: "auto",
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("reads defaults from a root that has no blur attributes", async () => {
|
|
37
|
+
expect(settingsOf((await rootOf()).attributes)).toMatchObject({
|
|
38
|
+
enabled: false,
|
|
39
|
+
shutterDeg: 240,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("is null for a document with nothing in it", async () => {
|
|
44
|
+
const session = await openComposition("<!DOCTYPE html><html><body></body></html>", {
|
|
45
|
+
history: false,
|
|
46
|
+
});
|
|
47
|
+
expect(findCompositionRoot(session.getRootElements())).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("changedEdits", () => {
|
|
52
|
+
it("lists only the attributes that would change", async () => {
|
|
53
|
+
const root = await rootOf(' data-motion-blur="on"');
|
|
54
|
+
const base = settingsOf(root.attributes);
|
|
55
|
+
expect(changedEdits(root.attributes, base)).toEqual([]);
|
|
56
|
+
expect(changedEdits(root.attributes, { ...base, shutterDeg: 360, tier: "filter" })).toEqual([
|
|
57
|
+
{ name: "data-motion-blur-shutter", value: "360" },
|
|
58
|
+
{ name: "data-motion-blur-tier", value: "filter" },
|
|
59
|
+
]);
|
|
60
|
+
expect(changedEdits(root.attributes, { ...base, enabled: false })).toEqual([
|
|
61
|
+
{ name: "data-motion-blur", value: null },
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("withEdits and sameAttributes", () => {
|
|
67
|
+
it("applies sets and removals without touching the input", () => {
|
|
68
|
+
const before = { "data-motion-blur": "on", "data-motion-blur-shutter": "360" };
|
|
69
|
+
const after = withEdits(before, [
|
|
70
|
+
{ name: "data-motion-blur-shutter", value: null },
|
|
71
|
+
{ name: "data-motion-blur-tier", value: "filter" },
|
|
72
|
+
]);
|
|
73
|
+
expect(after).toEqual({ "data-motion-blur": "on", "data-motion-blur-tier": "filter" });
|
|
74
|
+
expect(before).toEqual({ "data-motion-blur": "on", "data-motion-blur-shutter": "360" });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("compares attribute sets by content", () => {
|
|
78
|
+
expect(sameAttributes({ a: "1" }, { a: "1" })).toBe(true);
|
|
79
|
+
expect(sameAttributes({ a: "1" }, { a: "2" })).toBe(false);
|
|
80
|
+
expect(sameAttributes({ a: "1" }, {})).toBe(false);
|
|
81
|
+
expect(sameAttributes(null, {})).toBe(false);
|
|
82
|
+
expect(sameAttributes(null, null)).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("readPreviewRootAttributes", () => {
|
|
87
|
+
it("reads only the blur attributes from the preview's composition root", () => {
|
|
88
|
+
const doc = document.implementation.createHTMLDocument("p");
|
|
89
|
+
doc.body.innerHTML =
|
|
90
|
+
'<div data-composition-id="main" data-width="1920" data-motion-blur="on" data-motion-blur-shutter="360"></div>';
|
|
91
|
+
const frame = { contentDocument: doc } as unknown as HTMLIFrameElement;
|
|
92
|
+
expect(readPreviewRootAttributes(frame)).toEqual({
|
|
93
|
+
"data-motion-blur": "on",
|
|
94
|
+
"data-motion-blur-shutter": "360",
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("is null when there is no preview, no root, or the document cannot be read", () => {
|
|
99
|
+
expect(readPreviewRootAttributes(null)).toBeNull();
|
|
100
|
+
const empty = document.implementation.createHTMLDocument("p");
|
|
101
|
+
expect(
|
|
102
|
+
readPreviewRootAttributes({ contentDocument: empty } as unknown as HTMLIFrameElement),
|
|
103
|
+
).toBeNull();
|
|
104
|
+
const blocked = {
|
|
105
|
+
get contentDocument(): never {
|
|
106
|
+
throw new Error("cross-origin");
|
|
107
|
+
},
|
|
108
|
+
} as unknown as HTMLIFrameElement;
|
|
109
|
+
expect(readPreviewRootAttributes(blocked)).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("levelOf and patchForLevel", () => {
|
|
114
|
+
const on = (shutterDeg: number, enabled = true) => ({
|
|
115
|
+
enabled,
|
|
116
|
+
shutterDeg,
|
|
117
|
+
samples: null,
|
|
118
|
+
events: false,
|
|
119
|
+
tier: "auto" as const,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("reads off, subtle and strong from the root's settings", () => {
|
|
123
|
+
expect(levelOf(on(240, false))).toBe("off");
|
|
124
|
+
expect(levelOf(on(240))).toBe("subtle");
|
|
125
|
+
expect(levelOf(on(180))).toBe("subtle");
|
|
126
|
+
expect(levelOf(on(360))).toBe("subtle");
|
|
127
|
+
expect(levelOf(on(480))).toBe("subtle");
|
|
128
|
+
expect(levelOf(on(481))).toBe("strong");
|
|
129
|
+
expect(levelOf(on(720))).toBe("strong");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("sets Subtle to the default blur and Strong to the maximum, and leaves the rest alone for Off", () => {
|
|
133
|
+
expect(patchForLevel("subtle")).toEqual({ enabled: true, shutterDeg: 240, samples: null });
|
|
134
|
+
expect(patchForLevel("strong")).toEqual({ enabled: true, shutterDeg: 720, samples: 64 });
|
|
135
|
+
expect(patchForLevel("off")).toEqual({ enabled: false });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("reads back as the level that was chosen", () => {
|
|
139
|
+
for (const level of ["subtle", "strong"] as const) {
|
|
140
|
+
expect(levelOf({ ...on(300), ...patchForLevel(level) })).toBe(level);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** What the Motion blur panel reads from and writes to a composition root, and how it describes the preview. */
|
|
2
|
+
|
|
3
|
+
import type { ElementSnapshot } from "@hyperframes/sdk";
|
|
4
|
+
import {
|
|
5
|
+
MOTION_BLUR_ATTR,
|
|
6
|
+
MOTION_BLUR_LIMITS,
|
|
7
|
+
type MotionBlurSettings,
|
|
8
|
+
motionBlurAttributeEdits,
|
|
9
|
+
readMotionBlurSettings,
|
|
10
|
+
} from "@hyperframes/core/motion-blur-settings";
|
|
11
|
+
|
|
12
|
+
export interface CompositionRoot {
|
|
13
|
+
id: string;
|
|
14
|
+
attributes: Readonly<Record<string, string>>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The composition's own root: the top-level element that names a composition, else the first. */
|
|
18
|
+
export function findCompositionRoot(roots: readonly ElementSnapshot[]): CompositionRoot | null {
|
|
19
|
+
const root = roots.find((el) => "data-composition-id" in el.attributes) ?? roots[0];
|
|
20
|
+
return root ? { id: root.id, attributes: root.attributes } : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type RootAttributes = Readonly<Record<string, string>>;
|
|
24
|
+
|
|
25
|
+
export const settingsOf = (attributes: RootAttributes): MotionBlurSettings =>
|
|
26
|
+
readMotionBlurSettings((name) => attributes[name] ?? null);
|
|
27
|
+
|
|
28
|
+
/** Only the attributes whose value would change, so a no-op edit writes nothing. */
|
|
29
|
+
export function changedEdits(
|
|
30
|
+
attributes: RootAttributes,
|
|
31
|
+
next: MotionBlurSettings,
|
|
32
|
+
): Array<{ name: string; value: string | null }> {
|
|
33
|
+
return Object.entries(motionBlurAttributeEdits(next))
|
|
34
|
+
.filter(([name, value]) => (attributes[name] ?? null) !== value)
|
|
35
|
+
.map(([name, value]) => ({ name, value }));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `attributes` with `edits` applied: a value sets, null removes. */
|
|
39
|
+
export function withEdits(
|
|
40
|
+
attributes: RootAttributes,
|
|
41
|
+
edits: ReadonlyArray<{ name: string; value: string | null }>,
|
|
42
|
+
): RootAttributes {
|
|
43
|
+
const next: Record<string, string> = { ...attributes };
|
|
44
|
+
for (const { name, value } of edits) {
|
|
45
|
+
if (value === null) delete next[name];
|
|
46
|
+
else next[name] = value;
|
|
47
|
+
}
|
|
48
|
+
return next;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The blur attributes on the live preview's composition root: what actually runs, after an undo or an outside edit too. */
|
|
52
|
+
export function readPreviewRootAttributes(iframe: HTMLIFrameElement | null): RootAttributes | null {
|
|
53
|
+
try {
|
|
54
|
+
const root = iframe?.contentDocument?.querySelector("[data-composition-id]");
|
|
55
|
+
if (!root) return null;
|
|
56
|
+
const found: Record<string, string> = {};
|
|
57
|
+
for (const name of Object.values(MOTION_BLUR_ATTR)) {
|
|
58
|
+
const value = root.getAttribute(name);
|
|
59
|
+
if (value !== null) found[name] = value;
|
|
60
|
+
}
|
|
61
|
+
return found;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const sameAttributes = (a: RootAttributes | null, b: RootAttributes | null): boolean =>
|
|
68
|
+
a === b ||
|
|
69
|
+
(a !== null && b !== null && Object.keys({ ...a, ...b }).every((name) => a[name] === b[name]));
|
|
70
|
+
|
|
71
|
+
export type BlurLevel = "off" | "subtle" | "strong";
|
|
72
|
+
|
|
73
|
+
export const BLUR_LEVELS: ReadonlyArray<{ level: BlurLevel; label: string }> = [
|
|
74
|
+
{ level: "off", label: "Off" },
|
|
75
|
+
{ level: "subtle", label: "Subtle" },
|
|
76
|
+
{ level: "strong", label: "Strong" },
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
/** Subtle is the default blur; Strong opens the shutter and the samples as far as they go. */
|
|
80
|
+
const LEVEL_SETTINGS = {
|
|
81
|
+
subtle: { shutterDeg: MOTION_BLUR_LIMITS.shutterDeg.default, samples: null },
|
|
82
|
+
strong: {
|
|
83
|
+
shutterDeg: MOTION_BLUR_LIMITS.shutterDeg.max,
|
|
84
|
+
samples: MOTION_BLUR_LIMITS.samples.max,
|
|
85
|
+
},
|
|
86
|
+
} as const;
|
|
87
|
+
/** Halfway between the two shutters: above it a root reads as Strong, so a hand-set 360 reads as Subtle. */
|
|
88
|
+
const STRONG_ABOVE = (LEVEL_SETTINGS.subtle.shutterDeg + LEVEL_SETTINGS.strong.shutterDeg) / 2;
|
|
89
|
+
|
|
90
|
+
export function levelOf(settings: MotionBlurSettings): BlurLevel {
|
|
91
|
+
if (!settings.enabled) return "off";
|
|
92
|
+
return settings.shutterDeg > STRONG_ABOVE ? "strong" : "subtle";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** What choosing `level` changes; Off leaves the shutter, samples and every other setting where they were. */
|
|
96
|
+
export function patchForLevel(level: BlurLevel): Partial<MotionBlurSettings> {
|
|
97
|
+
return level === "off" ? { enabled: false } : { enabled: true, ...LEVEL_SETTINGS[level] };
|
|
98
|
+
}
|
|
@@ -105,7 +105,7 @@ describe("usePanelLayout over the dock", () => {
|
|
|
105
105
|
act(() => view.layout.setRightCollapsed(true));
|
|
106
106
|
const hidden = vi.mocked(controller.setGroupVisible).mock.calls;
|
|
107
107
|
expect(hidden.map(([id]) => id).sort()).toEqual(
|
|
108
|
-
["design", "layers", "renders", "slideshow", "variables"].sort(),
|
|
108
|
+
["design", "layers", "motionBlur", "renders", "slideshow", "variables"].sort(),
|
|
109
109
|
);
|
|
110
110
|
expect(hidden.every(([, visible]) => visible === false)).toBe(true);
|
|
111
111
|
});
|
|
@@ -140,7 +140,7 @@ describe("Filmstrip clips", () => {
|
|
|
140
140
|
const picture = host.querySelector(".timeline-clip__content");
|
|
141
141
|
expect(clip?.classList.contains("is-audio")).toBe(true);
|
|
142
142
|
expect(picture?.classList.contains("inset-0")).toBe(true);
|
|
143
|
-
expect(css).toMatch(/--timeline-clip-audio-radius:\s*
|
|
143
|
+
expect(css).toMatch(/--timeline-clip-audio-radius:\s*var\(--timeline-clip-radius\);/);
|
|
144
144
|
expect(clip instanceof HTMLElement && clip.style.borderRadius).toBe(
|
|
145
145
|
"var(--timeline-clip-audio-radius)",
|
|
146
146
|
);
|
|
@@ -92,7 +92,7 @@ describe("timeline motion styles", () => {
|
|
|
92
92
|
expect(themeCss).toContain("--timeline-clip-label-shadow: 0 1px 2px rgba(0, 0, 0, 0.85)");
|
|
93
93
|
expect(themeCss).toContain("--timeline-clip-selection-highlight: rgb(255 255 255 / 0.55)");
|
|
94
94
|
expect(themeCss).toContain("--timeline-clip-radius: 12px");
|
|
95
|
-
expect(themeCss).toContain("--timeline-clip-audio-radius:
|
|
95
|
+
expect(themeCss).toContain("--timeline-clip-audio-radius: var(--timeline-clip-radius)");
|
|
96
96
|
expect(themeCss).toContain("--timeline-clip-highlight: rgb(255 255 255 / 0.08)");
|
|
97
97
|
expect(themeCss).toContain("--timeline-clip-shadow: 0 2px 6px rgb(0 0 0 / 0.3)");
|
|
98
98
|
expect(themeCss).toContain("--timeline-clip-chip-bg: rgba(15, 22, 18, 0.73)");
|
package/src/styles/theme.css
CHANGED
|
@@ -123,7 +123,7 @@
|
|
|
123
123
|
--timeline-clip-band-height: 15px;
|
|
124
124
|
--timeline-clip-band-bg: rgb(12 12 14);
|
|
125
125
|
--timeline-clip-hover-duration: 60ms;
|
|
126
|
-
--timeline-clip-audio-radius:
|
|
126
|
+
--timeline-clip-audio-radius: var(--timeline-clip-radius);
|
|
127
127
|
--timeline-clip-highlight: rgb(255 255 255 / 0.08);
|
|
128
128
|
--timeline-clip-shadow: 0 2px 6px rgb(0 0 0 / 0.3);
|
|
129
129
|
--timeline-clip-chip-bg: rgba(15, 22, 18, 0.73);
|
|
@@ -28,7 +28,14 @@ export interface StudioUrlState {
|
|
|
28
28
|
selection: StudioUrlSelectionState | null;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
const VALID_TABS: RightPanelTab[] = [
|
|
31
|
+
const VALID_TABS: RightPanelTab[] = [
|
|
32
|
+
"layers",
|
|
33
|
+
"design",
|
|
34
|
+
"renders",
|
|
35
|
+
"slideshow",
|
|
36
|
+
"variables",
|
|
37
|
+
"motionBlur",
|
|
38
|
+
];
|
|
32
39
|
|
|
33
40
|
/**
|
|
34
41
|
* The composition auto-open and a schema-level panel (Variables / Slideshow)
|