@hyperframes/studio 0.8.189 → 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.
Files changed (36) hide show
  1. package/dist/assets/{hyperframes-player-CkK8Zijf.js → hyperframes-player-Bv9hQ3v7.js} +1 -1
  2. package/dist/assets/index-CBO8uz7-.css +1 -0
  3. package/dist/assets/{index-B2JcCaRF.js → index-D_QqfWhx.js} +133 -132
  4. package/dist/assets/{index-ATnRoBug.js → index-Df14bh-L.js} +1 -1
  5. package/dist/{chunk-563JPIL4.js → chunk-IWO4UENE.js} +8 -2
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4166 -3922
  9. package/dist/player.js +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +8 -8
  12. package/src/captions/parser.dom.test.ts +126 -0
  13. package/src/captions/parser.ts +45 -23
  14. package/src/components/StudioRightPanels.tsx +9 -0
  15. package/src/components/dock/DockTab.tsx +2 -0
  16. package/src/components/dock/dockAccessibility.test.ts +3 -3
  17. package/src/components/dock/dockLayout.ts +1 -1
  18. package/src/components/dock/panelRegistry.ts +6 -0
  19. package/src/components/editor/overlayFrameLoop.test.ts +42 -0
  20. package/src/components/editor/overlayFrameLoop.ts +11 -3
  21. package/src/components/panels/MotionBlurPanel.test.tsx +177 -0
  22. package/src/components/panels/MotionBlurPanel.tsx +163 -0
  23. package/src/components/panels/motionBlurPanelModel.test.ts +143 -0
  24. package/src/components/panels/motionBlurPanelModel.ts +98 -0
  25. package/src/components/renders/useRenderQueue.ts +0 -8
  26. package/src/hooks/usePanelLayout.test.ts +1 -1
  27. package/src/player/components/PlayerControls.test.tsx +67 -2
  28. package/src/player/components/PlayerControls.tsx +5 -3
  29. package/src/player/components/PreviewSeekBar.tsx +47 -0
  30. package/src/player/components/TimelineClip.filmstrip.test.tsx +1 -1
  31. package/src/player/components/timelineMotionStyles.test.ts +1 -1
  32. package/src/styles/theme.css +1 -1
  33. package/src/utils/studioHelpers.ts +2 -1
  34. package/src/utils/studioUrlState.ts +8 -1
  35. package/dist/assets/index-CESvLzz0.css +0 -1
  36. package/src/components/renders/useRenderQueueMotionBlur.test.tsx +0 -61
@@ -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
+ }
@@ -1,4 +1,3 @@
1
- import { htmlInCanvasForm } from "@hyperframes/core/html-in-canvas";
2
1
  import { buildProjectApiPath } from "../../utils/projectRouting";
3
2
  import { useState, useEffect, useCallback, useRef, useMemo } from "react";
4
3
  import type { CanvasResolution } from "@hyperframes/parsers";
@@ -11,11 +10,6 @@ import { ffmpegInstallMessage, useFfmpegStatus } from "./useFfmpegStatus";
11
10
  import { requestStudioFeedback, type FeedbackContext } from "../feedback/feedbackTrigger";
12
11
  import { studioApiFetch } from "../../utils/studioApiFetch";
13
12
 
14
- // WYSIWYG: the preview iframe runs in this browser, so a render blurs only if this one can.
15
- function canPreviewMotionBlur(): boolean {
16
- return typeof document !== "undefined" && htmlInCanvasForm() !== null;
17
- }
18
-
19
13
  export interface RenderJob {
20
14
  id: string;
21
15
  status: "rendering" | "complete" | "failed" | "cancelled";
@@ -244,12 +238,10 @@ export function useRenderQueue(
244
238
  gpu?: boolean;
245
239
  telemetryDistinctId?: string;
246
240
  telemetryOptOut?: boolean;
247
- previewMotionBlur: boolean;
248
241
  } = {
249
242
  fps,
250
243
  quality,
251
244
  format,
252
- previewMotionBlur: canPreviewMotionBlur(),
253
245
  };
254
246
  // The id is MINTED by getAnonymousId(), so calling it unconditionally
255
247
  // created a telemetry identity for a profile that had opted out — and
@@ -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
  });
@@ -2,7 +2,7 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import { liveTime, usePlayerStore } from "../store/playerStore";
7
7
  import { PlayerControls } from "./PlayerControls";
8
8
 
@@ -10,22 +10,87 @@ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
10
10
 
11
11
  let host: HTMLDivElement;
12
12
  let root: Root;
13
+ const onSeek = vi.fn();
14
+
15
+ function seekBar(): HTMLInputElement {
16
+ const input = host.querySelector('input[aria-label="Seek preview"]');
17
+ if (!(input instanceof HTMLInputElement)) throw new Error("Preview seek bar is missing");
18
+ return input;
19
+ }
13
20
 
14
21
  beforeEach(async () => {
22
+ onSeek.mockClear();
15
23
  usePlayerStore.setState({
16
24
  isPlaying: true,
17
25
  currentTime: 2,
18
26
  duration: 30,
19
27
  timeDisplayMode: "time",
28
+ timelineReady: true,
20
29
  });
21
30
  host = document.createElement("div");
22
31
  document.body.append(host);
23
32
  root = createRoot(host);
24
33
  await act(async () =>
25
- root.render(React.createElement(PlayerControls, { onTogglePlay: () => {}, onSeek: () => {} })),
34
+ root.render(React.createElement(PlayerControls, { onTogglePlay: () => {}, onSeek })),
26
35
  );
27
36
  });
28
37
 
38
+ describe("PlayerControls preview seeking", () => {
39
+ it("shows the current position and follows the playback clock", () => {
40
+ const input = seekBar();
41
+ expect(input.type).toBe("range");
42
+ expect(input.valueAsNumber).toBe(2);
43
+ expect(input.max).toBe("30");
44
+ expect(input.disabled).toBe(false);
45
+
46
+ liveTime.notify(12.5);
47
+ expect(input.valueAsNumber).toBe(12.5);
48
+ expect(input.getAttribute("aria-valuetext")).toBe("00:12 of 00:30");
49
+ });
50
+
51
+ it("seeks continuously and reaches both ends of the preview", () => {
52
+ const input = seekBar();
53
+ const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
54
+ if (!setValue) throw new Error("Native range value setter is missing");
55
+ for (const time of [8.25, 15.5, 30, 0]) {
56
+ act(() => {
57
+ setValue.call(input, String(time));
58
+ input.dispatchEvent(new Event("input", { bubbles: true }));
59
+ });
60
+ expect(onSeek).toHaveBeenLastCalledWith(time);
61
+ }
62
+ expect(onSeek).toHaveBeenCalledTimes(4);
63
+ });
64
+
65
+ it("updates its duration and resets its position for a new composition", async () => {
66
+ await act(async () => usePlayerStore.setState({ duration: 5, currentTime: 0 }));
67
+ expect(seekBar().max).toBe("5");
68
+ expect(seekBar().valueAsNumber).toBe(0);
69
+ expect(seekBar().getAttribute("aria-valuetext")).toBe("00:00 of 00:05");
70
+ });
71
+
72
+ it("disables seeking until the preview is ready or has a duration", async () => {
73
+ await act(async () => usePlayerStore.setState({ timelineReady: false }));
74
+ expect(seekBar().disabled).toBe(true);
75
+ await act(async () => usePlayerStore.setState({ timelineReady: true, duration: 0 }));
76
+ expect(seekBar().disabled).toBe(true);
77
+ });
78
+
79
+ it("respects the disabled prop in fullscreen", async () => {
80
+ await act(async () =>
81
+ root.render(
82
+ React.createElement(PlayerControls, {
83
+ onTogglePlay: () => {},
84
+ onSeek,
85
+ disabled: true,
86
+ isFullscreen: true,
87
+ }),
88
+ ),
89
+ );
90
+ expect(seekBar().disabled).toBe(true);
91
+ });
92
+ });
93
+
29
94
  afterEach(async () => {
30
95
  await act(async () => root.unmount());
31
96
  host.remove();
@@ -10,6 +10,7 @@ import { ShortcutsButton } from "./ShortcutsPanel";
10
10
  import type { ShortcutSection } from "./studioShortcuts";
11
11
  import { SpeedMenu } from "./SpeedMenu";
12
12
  import { VolumeControl } from "./VolumeControl";
13
+ import { PreviewSeekBar } from "./PreviewSeekBar";
13
14
 
14
15
  /* ── Icon sub-components ─────────────────────────────────────────── */
15
16
 
@@ -221,9 +222,10 @@ export const PlayerControls = memo(function PlayerControls({
221
222
  });
222
223
 
223
224
  return (
224
- <div>
225
+ <div className="relative z-20">
226
+ <PreviewSeekBar duration={duration} disabled={controlsDisabled} onSeek={onSeek} />
225
227
  <div
226
- className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3"
228
+ className="grid h-7 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3"
227
229
  aria-disabled={disabled || undefined}
228
230
  style={{
229
231
  paddingBottom: "env(safe-area-inset-bottom)",
@@ -257,7 +259,7 @@ export const PlayerControls = memo(function PlayerControls({
257
259
  onTogglePlay();
258
260
  }}
259
261
  disabled={controlsDisabled}
260
- className="flex h-8 w-8 items-center justify-center justify-self-center rounded-md text-neutral-100 transition-colors hover:text-white disabled:pointer-events-none disabled:opacity-30"
262
+ className="flex h-7 w-8 items-center justify-center justify-self-center rounded-md text-neutral-100 transition-colors hover:text-white disabled:pointer-events-none disabled:opacity-30"
261
263
  >
262
264
  <PlayPauseMorphIcon playing={isPlaying} />
263
265
  </button>
@@ -0,0 +1,47 @@
1
+ import { memo, useEffect, useRef } from "react";
2
+ import { formatTime } from "../lib/time";
3
+ import { liveTime, usePlayerStore } from "../store/playerStore";
4
+
5
+ export const PreviewSeekBar = memo(function PreviewSeekBar({
6
+ duration,
7
+ disabled,
8
+ onSeek,
9
+ }: {
10
+ duration: number;
11
+ disabled: boolean;
12
+ onSeek: (time: number) => void;
13
+ }) {
14
+ const inputRef = useRef<HTMLInputElement>(null);
15
+
16
+ useEffect(() => {
17
+ const input = inputRef.current;
18
+ if (!input) return;
19
+ const updateTime = (time: number) => {
20
+ input.value = String(time);
21
+ const text = `${formatTime(time)} of ${formatTime(duration)}`;
22
+ if (input.getAttribute("aria-valuetext") !== text) input.setAttribute("aria-valuetext", text);
23
+ };
24
+ updateTime(usePlayerStore.getState().currentTime);
25
+ const unsubscribe = liveTime.subscribe(updateTime);
26
+ return () => {
27
+ unsubscribe();
28
+ };
29
+ }, [duration]);
30
+
31
+ return (
32
+ <div className="flex h-3 items-center px-3">
33
+ <input
34
+ ref={inputRef}
35
+ type="range"
36
+ min="0"
37
+ max={duration}
38
+ step="any"
39
+ defaultValue={0}
40
+ disabled={disabled || duration <= 0}
41
+ aria-label="Seek preview"
42
+ onChange={(event) => onSeek(event.currentTarget.valueAsNumber)}
43
+ className="h-6 w-full cursor-pointer accent-neutral-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-ink disabled:cursor-default disabled:opacity-30"
44
+ />
45
+ </div>
46
+ );
47
+ });
@@ -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*21px;/);
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: 21px");
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)");
@@ -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: 21px;
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);
@@ -23,7 +23,8 @@ export type RightPanelTab =
23
23
  | "renders"
24
24
  | "block-params"
25
25
  | "slideshow"
26
- | "variables";
26
+ | "variables"
27
+ | "motionBlur";
27
28
  export interface AgentModalAnchorPoint {
28
29
  x: number;
29
30
  y: number;
@@ -28,7 +28,14 @@ export interface StudioUrlState {
28
28
  selection: StudioUrlSelectionState | null;
29
29
  }
30
30
 
31
- const VALID_TABS: RightPanelTab[] = ["layers", "design", "renders", "slideshow", "variables"];
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)