@hyperframes/studio 0.8.190 → 0.8.192

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 (34) hide show
  1. package/dist/assets/{hyperframes-player-0HXJPmYC.js → hyperframes-player-ChN3cOr0.js} +3 -3
  2. package/dist/assets/{index-DsSfhjUS.js → index-BYbFcRR0.js} +1 -1
  3. package/dist/assets/{index-BYw97F07.js → index-CHu83hHM.js} +126 -125
  4. package/dist/assets/{index-B3vk3hfU.css → index-CYbvOVp-.css} +1 -1
  5. package/dist/{chunk-563JPIL4.js → chunk-6SXUUBPD.js} +9 -3
  6. package/dist/{chunk-FF5DM7BQ.js → chunk-7HZGKFFU.js} +18 -4
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.html +2 -2
  9. package/dist/index.js +509 -282
  10. package/dist/player.js +2 -2
  11. package/dist/styles.css +1 -1
  12. package/dist/ui.d.ts +3 -1
  13. package/dist/ui.js +1 -1
  14. package/package.json +9 -8
  15. package/src/captions/parser.dom.test.ts +126 -0
  16. package/src/captions/parser.test.ts +71 -1
  17. package/src/captions/parser.ts +121 -65
  18. package/src/components/StudioRightPanels.tsx +9 -0
  19. package/src/components/dock/DockTab.tsx +2 -0
  20. package/src/components/dock/dockAccessibility.test.ts +3 -3
  21. package/src/components/dock/dockLayout.ts +1 -1
  22. package/src/components/dock/panelRegistry.ts +6 -0
  23. package/src/components/panels/MotionBlurPanel.test.tsx +177 -0
  24. package/src/components/panels/MotionBlurPanel.tsx +163 -0
  25. package/src/components/panels/motionBlurPanelModel.test.ts +143 -0
  26. package/src/components/panels/motionBlurPanelModel.ts +98 -0
  27. package/src/components/ui/Tooltip.test.tsx +32 -0
  28. package/src/components/ui/Tooltip.tsx +23 -3
  29. package/src/hooks/usePanelLayout.test.ts +1 -1
  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
package/dist/ui.d.ts CHANGED
@@ -95,8 +95,10 @@ interface TooltipProps {
95
95
  delay?: number;
96
96
  disabled?: boolean;
97
97
  side?: "top" | "bottom" | "left" | "right";
98
+ /** Wraps a long label at a reading width and cuts it at this many lines with an ellipsis. Unset, one line. */
99
+ lines?: number;
98
100
  }
99
- declare function Tooltip({ label, children, delay, side, disabled, }: TooltipProps): react_jsx_runtime.JSX.Element;
101
+ declare function Tooltip({ label, children, delay, side, disabled, lines, }: TooltipProps): react_jsx_runtime.JSX.Element;
100
102
 
101
103
  /**
102
104
  * Studio's tailwind-merge config: teaches it the two scales `theme.css`
package/dist/ui.js CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  fieldBase,
31
31
  fieldText,
32
32
  popupSurface
33
- } from "./chunk-FF5DM7BQ.js";
33
+ } from "./chunk-7HZGKFFU.js";
34
34
  import "./chunk-MLKGABMK.js";
35
35
  export {
36
36
  Button,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.190",
3
+ "version": "0.8.192",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -55,16 +55,17 @@
55
55
  "@mcp-b/global": "^5.0.1",
56
56
  "@phosphor-icons/react": "^2.1.10",
57
57
  "@tanstack/react-virtual": "^3.14.13",
58
+ "acorn": "^8.17.0",
58
59
  "cn": "0.2.4",
59
60
  "dockview-react": "8.3.1",
60
61
  "gsap": "^3.13.0",
61
62
  "mediabunny": "^1.45.3",
62
- "@hyperframes/core": "0.8.190",
63
- "@hyperframes/parsers": "0.8.190",
64
- "@hyperframes/sdk": "0.8.190",
65
- "@hyperframes/player": "0.8.190",
66
- "@hyperframes/shader-transitions": "0.8.190",
67
- "@hyperframes/studio-server": "0.8.190"
63
+ "@hyperframes/core": "0.8.192",
64
+ "@hyperframes/parsers": "0.8.192",
65
+ "@hyperframes/player": "0.8.192",
66
+ "@hyperframes/sdk": "0.8.192",
67
+ "@hyperframes/shader-transitions": "0.8.192",
68
+ "@hyperframes/studio-server": "0.8.192"
68
69
  },
69
70
  "devDependencies": {
70
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +82,7 @@
81
82
  "vite": "^6.4.2",
82
83
  "vitest": "^4.1.11",
83
84
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.190"
85
+ "@hyperframes/producer": "0.8.192"
85
86
  },
86
87
  "peerDependencies": {
87
88
  "react": "19",
@@ -0,0 +1,126 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from "vitest";
3
+ import { generateCaptionHtml } from "./generator";
4
+ import { parseCaptionComposition } from "./parser";
5
+
6
+ const transcript = "We asked what you needed. Forty-seven percent".split(" ").map((text, i) => ({
7
+ id: `word-${i}`,
8
+ text,
9
+ start: i * 0.5,
10
+ end: i * 0.5 + 0.4,
11
+ }));
12
+ const source = `const TRANSCRIPT = ${JSON.stringify(transcript)};`;
13
+
14
+ function createCaptionDocument(
15
+ groupSizes: number[],
16
+ groupClass = "caption-group",
17
+ wordClass = "word",
18
+ ) {
19
+ const doc = document.implementation.createHTMLDocument();
20
+ let offset = 0;
21
+ for (const size of groupSizes) {
22
+ const group = doc.createElement("div");
23
+ group.className = groupClass;
24
+ for (const word of transcript.slice(offset, offset + size)) {
25
+ const span = doc.createElement("span");
26
+ span.className = wordClass;
27
+ span.textContent = word.text;
28
+ group.appendChild(span);
29
+ }
30
+ doc.body.appendChild(group);
31
+ offset += size;
32
+ }
33
+ return doc;
34
+ }
35
+
36
+ function importCaptions(doc: Document, captionSource = source) {
37
+ const model = parseCaptionComposition(doc, window, captionSource, 1920, 1080, 4);
38
+ expect(model).not.toBeNull();
39
+ if (!model) throw new Error("Expected a caption model");
40
+ const groups = model.groupOrder.map((id) =>
41
+ model.groups.get(id)?.segmentIds.map((segmentId) => model.segments.get(segmentId)?.text),
42
+ );
43
+ return { model, groups };
44
+ }
45
+
46
+ describe("parseCaptionComposition grouping", () => {
47
+ it.each([{ sizes: [5, 2] }, { sizes: [2, 4, 1] }])(
48
+ "preserves authored group sizes $sizes",
49
+ ({ sizes }) => {
50
+ const { model, groups } = importCaptions(createCaptionDocument(sizes));
51
+ expect(groups.map((group) => group?.length)).toEqual(sizes);
52
+ expect(groups.flat()).toEqual(transcript.map((word) => word.text));
53
+ expect(
54
+ [...model.segments.values()].map(({ wordId, text, start, end }) => ({
55
+ id: wordId,
56
+ text,
57
+ start,
58
+ end,
59
+ })),
60
+ ).toEqual(transcript);
61
+ for (const group of model.groups.values()) {
62
+ expect(group.segmentIds.map((id) => model.segments.get(id)?.groupIndex)).toEqual(
63
+ group.segmentIds.map((_, index) => index),
64
+ );
65
+ }
66
+ },
67
+ );
68
+
69
+ it.each(["caption-line", "caption-block"])(
70
+ "supports %s with caption-word spans",
71
+ (groupClass) => {
72
+ const { groups } = importCaptions(
73
+ createCaptionDocument([2, 4, 1], groupClass, "caption-word"),
74
+ );
75
+ expect(groups.map((group) => group?.length)).toEqual([2, 4, 1]);
76
+ },
77
+ );
78
+
79
+ it("does not count nested caption lines as additional groups", () => {
80
+ const doc = createCaptionDocument([2, 4, 1]);
81
+ for (const group of doc.querySelectorAll(".caption-group")) {
82
+ const line = doc.createElement("div");
83
+ line.className = "caption-line";
84
+ line.append(...group.childNodes);
85
+ group.appendChild(line);
86
+ }
87
+ expect(importCaptions(doc).groups.map((group) => group?.length)).toEqual([2, 4, 1]);
88
+ });
89
+
90
+ it.each([
91
+ { name: "absent", sizes: [] },
92
+ { name: "incomplete", sizes: [2, 1] },
93
+ { name: "empty", sizes: [0, 0] },
94
+ ])("uses default grouping without losing words when DOM groups are $name", ({ sizes }) => {
95
+ const { groups } = importCaptions(createCaptionDocument(sizes));
96
+ expect(groups).toEqual([
97
+ transcript.slice(0, 5).map((word) => word.text),
98
+ transcript.slice(5).map((word) => word.text),
99
+ ]);
100
+ });
101
+
102
+ it("keeps authored boundaries when generated captions are imported again", () => {
103
+ const original = importCaptions(createCaptionDocument([2, 4, 1]));
104
+ const html = generateCaptionHtml(original.model);
105
+ const doc = document.implementation.createHTMLDocument();
106
+ doc.body.innerHTML = html;
107
+ const template = doc.querySelector("template");
108
+ if (!template) throw new Error("Expected generated caption template");
109
+ doc.body.replaceChildren(template.content.cloneNode(true));
110
+ const script = doc.querySelector("script")?.textContent;
111
+ if (!script) throw new Error("Expected generated caption script");
112
+ // Execute only our generator's output to create its real group and word elements.
113
+ new Function("document", "window", "gsap", script)(
114
+ doc,
115
+ {},
116
+ {
117
+ timeline: () => ({ to() {} }),
118
+ },
119
+ );
120
+ expect(importCaptions(doc, html).groups).toEqual([
121
+ transcript.slice(0, 2).map((word) => word.text),
122
+ transcript.slice(2, 6).map((word) => word.text),
123
+ transcript.slice(6).map((word) => word.text),
124
+ ]);
125
+ });
126
+ });
@@ -1,5 +1,5 @@
1
1
  // @vitest-environment node
2
- import { describe, it, expect } from "vitest";
2
+ import { describe, it, expect, vi } from "vitest";
3
3
  import { extractTranscript, buildCaptionModel, TranscriptWord } from "./parser";
4
4
  import { DEFAULT_STYLE, DEFAULT_CONTAINER, DEFAULT_ANIMATION_SET } from "./types";
5
5
 
@@ -156,6 +156,76 @@ describe("extractTranscript", () => {
156
156
  });
157
157
  });
158
158
 
159
+ it.each([
160
+ ["array delimiter", "'x ]; y'", "x ]; y"],
161
+ ["trailing-comma text", "'literal ,] and ,}'", "literal ,] and ,}"],
162
+ ["object-key text", "'{ text: example }'", "{ text: example }"],
163
+ ["escaped quotes", String.raw`'It\'s "quoted"'`, `It's "quoted"`],
164
+ ["escaped double quotes", String.raw`'Say \"hello\"'`, 'Say "hello"'],
165
+ ["escaped backslash", String.raw`'C:\\captions\\'`, "C:\\captions\\"],
166
+ ["hex and Unicode escapes", String.raw`'\x41\u0042\u{1D11E}'`, "AB\u{1D11E}"],
167
+ ["control escapes", String.raw`'one\ntwo\tthree'`, "one\ntwo\tthree"],
168
+ ])("preserves %s in JavaScript strings", (_name, literal, text) => {
169
+ const source = `<script>const TRANSCRIPT = [{ id: 'word-a', text: ${literal}, start: 0.25, end: 1.5 }];</script>`;
170
+ expect(extractTranscript(source)).toEqual([{ id: "word-a", text, start: 0.25, end: 1.5 }]);
171
+ });
172
+
173
+ it("preserves JSON values and filters malformed word entries", () => {
174
+ const source = `const TRANSCRIPT = [
175
+ null, 42, {"text":"missing timing"}, {"text":"bad timing","start":"0","end":1},
176
+ {"id":7,"text":"valid","start":-0.25,"end":1e1,"extra":{"items":[true,false,null]}}
177
+ ];`;
178
+ expect(extractTranscript(source)).toEqual([{ text: "valid", start: -0.25, end: 10 }]);
179
+ });
180
+
181
+ it("accepts comments around static values", () => {
182
+ expect(
183
+ extractTranscript(`const TRANSCRIPT = [
184
+ // Delimiter in a comment: ];
185
+ { text: 'hello', start: 0, end: 1, },
186
+ ] /* trailing comment */;`),
187
+ ).toEqual([{ text: "hello", start: 0, end: 1 }]);
188
+ });
189
+
190
+ it.each([
191
+ "[{ text: 'unterminated, start: 0, end: 1 }];",
192
+ "[{ text: 'hello', start: 0, end: 1 };",
193
+ String.raw`[{ text: '\xZZ', start: 0, end: 1 }];`,
194
+ "[{ text: 'hello', start: 0, end: 1 }] garbage;",
195
+ "[{ text: 'hello', start: 0, end: 1 },,];",
196
+ "[{ text: getText(), start: 0, end: 1 }];",
197
+ "[{ text: 'hello', start: offset, end: 1 }];",
198
+ "[{ text: 'hello', start: 1 + 2, end: 4 }];",
199
+ "[{ text: 'hello', start: -getTime(), end: 1 }];",
200
+ "[{ text: 'hello', start: 0, end: 1, extra: /pattern/ }];",
201
+ "[{ text: 'hello', start: 0, end: 1, extra: 1n }];",
202
+ "[{ get text() { return 'hello'; }, start: 0, end: 1 }];",
203
+ "[{ text: 'hello', start: 0, end: 1, extra() {} }];",
204
+ "[{ ['text']: 'hello', start: 0, end: 1 }];",
205
+ "[{ text, start: 0, end: 1 }];",
206
+ "[{ ...word, text: 'hello', start: 0, end: 1 }];",
207
+ "[...words];",
208
+ "[{ text: `hello ${getText()}`, start: 0, end: 1 }];",
209
+ "[{ text: 'hello', start: 0, end: 1, extra: { run: getText() } }];",
210
+ "[{ text: 'hello', start: 0, end: 1 }].map(transform);",
211
+ "[{ text: 'hello', start: 0, end: 1 }], run();",
212
+ ])("rejects malformed or executable initializers: %s", (initializer) => {
213
+ expect(extractTranscript(`const TRANSCRIPT = ${initializer}`)).toEqual([]);
214
+ });
215
+
216
+ it("never runs code while extracting a transcript", () => {
217
+ const getCaptionText = vi.fn(() => "hello");
218
+ vi.stubGlobal("getCaptionText", getCaptionText);
219
+ try {
220
+ expect(
221
+ extractTranscript("const TRANSCRIPT = [{ text: getCaptionText(), start: 0, end: 1 }];"),
222
+ ).toEqual([]);
223
+ expect(getCaptionText).not.toHaveBeenCalled();
224
+ } finally {
225
+ vi.unstubAllGlobals();
226
+ }
227
+ });
228
+
159
229
  describe("real-world source samples", () => {
160
230
  it("handles a realistic production-style TRANSCRIPT block with many words", () => {
161
231
  const source = `
@@ -2,6 +2,14 @@
2
2
  // Parses a caption composition's JavaScript source to extract the transcript word array,
3
3
  // and builds a CaptionModel from a TranscriptWord array.
4
4
 
5
+ import {
6
+ parseExpressionAt,
7
+ tokenizer,
8
+ type Expression,
9
+ type Property,
10
+ type SpreadElement,
11
+ type UnaryExpression,
12
+ } from "acorn";
5
13
  import {
6
14
  CaptionModel,
7
15
  CaptionSegment,
@@ -38,23 +46,32 @@ export function buildCaptionModel(
38
46
  transcript: TranscriptWord[],
39
47
  options: BuildOptions,
40
48
  ): CaptionModel {
41
- const { width, height, duration, wordsPerGroup = 5 } = options;
49
+ const { wordsPerGroup = 5 } = options;
50
+ const wordGroups: TranscriptWord[][] = [];
51
+ for (let offset = 0; offset < transcript.length; offset += wordsPerGroup) {
52
+ wordGroups.push(transcript.slice(offset, offset + wordsPerGroup));
53
+ }
54
+ return buildGroupedCaptionModel(wordGroups, options);
55
+ }
42
56
 
57
+ function buildGroupedCaptionModel(
58
+ wordGroups: TranscriptWord[][],
59
+ { width, height, duration }: BuildOptions,
60
+ ): CaptionModel {
43
61
  const segments = new Map<string, CaptionSegment>();
44
62
  const groups = new Map<string, CaptionGroup>();
45
63
  const groupOrder: string[] = [];
46
64
 
47
- // Chunk the transcript into groups of wordsPerGroup
48
- for (let groupIdx = 0; groupIdx < transcript.length; groupIdx += wordsPerGroup) {
49
- const chunk = transcript.slice(groupIdx, groupIdx + wordsPerGroup);
50
- const groupId = `group-${groupIdx / wordsPerGroup}`;
65
+ for (const chunk of wordGroups) {
66
+ const groupId = `group-${groups.size}`;
51
67
  const segmentIds: string[] = [];
52
68
 
53
69
  chunk.forEach((word, wordIdx) => {
54
- const segmentId = `segment-${groupIdx + wordIdx}`;
70
+ const segmentIndex = segments.size;
71
+ const segmentId = `segment-${segmentIndex}`;
55
72
  const segment: CaptionSegment = {
56
73
  id: segmentId,
57
- wordId: word.id ?? `w${groupIdx + wordIdx}`,
74
+ wordId: word.id ?? `w${segmentIndex}`,
58
75
  text: word.text,
59
76
  start: word.start,
60
77
  end: word.end,
@@ -96,30 +113,50 @@ export function buildCaptionModel(
96
113
  };
97
114
  }
98
115
 
116
+ const GROUP_SELECTOR = ".caption-group, .caption-line, .caption-block";
117
+ const WORD_SELECTOR = ".word, .caption-word";
118
+
119
+ /** Read outer groups in transcript order; return null if their word counts do not match. */
120
+ function readTranscriptGroups(transcript: TranscriptWord[], groupEls: NodeListOf<Element>) {
121
+ const wordGroups: TranscriptWord[][] = [];
122
+ let offset = 0;
123
+ for (const group of groupEls) {
124
+ // Caption templates can nest layout lines inside a single timed group.
125
+ if (group.parentElement?.closest(GROUP_SELECTOR)) continue;
126
+ const size = group.querySelectorAll(WORD_SELECTOR).length;
127
+ if (size === 0) continue;
128
+ wordGroups.push(transcript.slice(offset, offset + size));
129
+ offset += size;
130
+ }
131
+ return offset === transcript.length ? wordGroups : null;
132
+ }
133
+
99
134
  /**
100
135
  * Extracts a transcript word array from caption composition source code.
101
136
  *
102
137
  * Looks for `const TRANSCRIPT = [...]` or `const script = [...]` (also let/var)
103
138
  * and parses each `{ text, start, end }` object into TranscriptWord objects.
104
139
  *
140
+ * Supports static literals only; expressions are never evaluated.
105
141
  * Returns an empty array if no transcript is found or if parsing fails.
106
142
  */
107
143
  export function extractTranscript(source: string): TranscriptWord[] {
108
- // Match: (const|let|var) (TRANSCRIPT|script) = [...]
109
- // The array may span multiple lines and contain trailing commas.
110
- // The lazy [\s\S]*? anchors on the first `];` — assumes transcript word
111
- // text never contains a literal `];` string (safe for speech transcripts).
112
- const varPattern = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*(\[[\s\S]*?\]);/;
113
- const match = source.match(varPattern);
144
+ // Locate the initializer in either JavaScript or a complete HTML composition.
145
+ // Acorn owns its boundary so delimiters inside strings remain literal text.
146
+ const varPattern = /\b(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*(?=\[)/;
147
+ const match = varPattern.exec(source);
114
148
 
115
149
  if (!match) {
116
150
  return [];
117
151
  }
118
152
 
119
- const arrayLiteral = match[1];
120
-
121
153
  try {
122
- return parseTranscriptArray(arrayLiteral);
154
+ const options = { ecmaVersion: "latest" } as const;
155
+ const expression = parseExpressionAt(source, match.index + match[0].length, options);
156
+ if (expression.type !== "ArrayExpression") return [];
157
+ const nextToken = tokenizer(source.slice(expression.end), options).getToken();
158
+ if (nextToken.type.label !== ";") return [];
159
+ return parseTranscriptArray(readStaticValue(expression));
123
160
  } catch {
124
161
  return [];
125
162
  }
@@ -130,7 +167,8 @@ export function extractTranscript(source: string): TranscriptWord[] {
130
167
  * from the source and reading computed styles from rendered elements.
131
168
  *
132
169
  * Runs in the Studio (outside the iframe). Reads computed styles from iframe DOM
133
- * elements to build a fully-styled CaptionModel.
170
+ * elements to build a fully-styled CaptionModel. Preserves DOM group boundaries
171
+ * when they account for every transcript word; otherwise uses default grouping.
134
172
  *
135
173
  * Returns null if no transcript is found in the source.
136
174
  */
@@ -149,25 +187,19 @@ export function parseCaptionComposition(
149
187
  }
150
188
 
151
189
  // Step 2: Look for grouping and word elements in the iframe DOM
152
- const groupEls = iframeDoc.querySelectorAll(".caption-group, .caption-line, .caption-block");
153
- const wordEls = iframeDoc.querySelectorAll(".word, .caption-word");
154
-
155
- // Step 3: Infer wordsPerGroup from element counts
156
- let wordsPerGroup = 5; // default
157
- if (groupEls.length > 0 && wordEls.length > 0) {
158
- wordsPerGroup = Math.round(wordEls.length / groupEls.length);
159
- if (wordsPerGroup < 1) {
160
- wordsPerGroup = 1;
161
- }
162
- }
190
+ const groupEls = iframeDoc.querySelectorAll(GROUP_SELECTOR);
191
+ const wordEls = iframeDoc.querySelectorAll(WORD_SELECTOR);
163
192
 
164
- // Step 4: Build the caption model with inferred grouping
165
- const model = buildCaptionModel(transcript, {
193
+ // Preserve authored boundaries when the DOM accounts for the complete transcript.
194
+ const wordGroups = readTranscriptGroups(transcript, groupEls);
195
+ const options = {
166
196
  width: compositionWidth,
167
197
  height: compositionHeight,
168
198
  duration: compositionDuration,
169
- wordsPerGroup,
170
- });
199
+ };
200
+ const model = wordGroups
201
+ ? buildGroupedCaptionModel(wordGroups, options)
202
+ : buildCaptionModel(transcript, options);
171
203
 
172
204
  // Step 5: Read computed styles from the first word or group element
173
205
  const firstWordEl = wordEls.item(0) as Element | null;
@@ -262,51 +294,75 @@ export function parseCaptionComposition(
262
294
  return model;
263
295
  }
264
296
 
265
- /**
266
- * Parses a JS array literal containing `{ text, start, end }` objects.
267
- *
268
- * Handles:
269
- * - Double-quoted and single-quoted string values
270
- * - Trailing commas after the last element or property
271
- * - Unquoted property keys (standard JS object literal syntax)
272
- * - Numeric values for start/end
273
- */
274
- function parseTranscriptArray(arrayLiteral: string): TranscriptWord[] {
275
- // Try parsing as-is first (handles already-valid JSON)
276
- let parsed: unknown;
277
- try {
278
- parsed = JSON.parse(arrayLiteral);
279
- } catch {
280
- // Not valid JSON — normalize single quotes, unquoted keys, trailing commas
281
- let normalized = arrayLiteral;
282
- normalized = normalized.replace(/'((?:[^'\\]|\\.)*)'/g, (_match, inner) => {
283
- const escaped = inner.replace(/\\'/g, "'").replace(/"/g, '\\"');
284
- return `"${escaped}"`;
285
- });
286
- normalized = normalized.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":');
287
- normalized = normalized.replace(/,(\s*[}\]])/g, "$1");
288
- parsed = JSON.parse(normalized);
297
+ /** Decode data literals only, including metadata that is not used by captions. */
298
+ function readStaticValue(node: Expression | SpreadElement | null): unknown {
299
+ switch (node?.type) {
300
+ case "Literal":
301
+ // Acorn's remaining literals are strings, numbers, booleans, or null.
302
+ if ("regex" in node || "bigint" in node) break;
303
+ return node.value;
304
+ case "UnaryExpression":
305
+ return readSignedNumber(node);
306
+ case "ArrayExpression":
307
+ return node.elements.map(readStaticValue);
308
+ case "ObjectExpression":
309
+ return Object.fromEntries(node.properties.map(readStaticProperty));
310
+ }
311
+ throw new SyntaxError("Transcript values must be static literals");
312
+ }
313
+
314
+ function readSignedNumber(node: UnaryExpression): number {
315
+ if (
316
+ (node.operator !== "-" && node.operator !== "+") ||
317
+ node.argument.type !== "Literal" ||
318
+ typeof node.argument.value !== "number"
319
+ ) {
320
+ throw new SyntaxError("Transcript unary expressions must be signed numeric literals");
321
+ }
322
+ return node.operator === "-" ? -node.argument.value : node.argument.value;
323
+ }
324
+
325
+ function readStaticProperty(property: Property | SpreadElement): [string | number, unknown] {
326
+ if (
327
+ property.type !== "Property" ||
328
+ property.kind !== "init" ||
329
+ property.method ||
330
+ property.shorthand ||
331
+ property.computed
332
+ ) {
333
+ throw new SyntaxError("Transcript properties must be static data");
334
+ }
335
+ const key =
336
+ property.key.type === "Identifier" ? property.key.name : readStaticValue(property.key);
337
+ if (typeof key !== "string" && typeof key !== "number") {
338
+ throw new SyntaxError("Transcript property keys must be names or literals");
289
339
  }
340
+ return [key, readStaticValue(property.value)];
341
+ }
290
342
 
343
+ function parseTranscriptArray(parsed: unknown): TranscriptWord[] {
291
344
  if (!Array.isArray(parsed)) {
292
345
  return [];
293
346
  }
294
347
 
295
348
  const words: TranscriptWord[] = [];
296
- for (const item of parsed) {
349
+ const items: unknown[] = parsed;
350
+ for (const item of items) {
297
351
  if (
298
352
  item !== null &&
299
353
  typeof item === "object" &&
300
- typeof (item as Record<string, unknown>).text === "string" &&
301
- typeof (item as Record<string, unknown>).start === "number" &&
302
- typeof (item as Record<string, unknown>).end === "number"
354
+ "text" in item &&
355
+ typeof item.text === "string" &&
356
+ "start" in item &&
357
+ typeof item.start === "number" &&
358
+ "end" in item &&
359
+ typeof item.end === "number"
303
360
  ) {
304
- const entry = item as Record<string, unknown>;
305
361
  words.push({
306
- ...(typeof entry.id === "string" ? { id: entry.id } : {}),
307
- text: entry.text as string,
308
- start: entry.start as number,
309
- end: entry.end as number,
362
+ ...("id" in item && typeof item.id === "string" ? { id: item.id } : {}),
363
+ text: item.text,
364
+ start: item.start,
365
+ end: item.end,
310
366
  });
311
367
  }
312
368
  }
@@ -8,6 +8,7 @@ import { BlockParamsPanel } from "./editor/BlockParamsPanel";
8
8
  import { RenderQueuePanel } from "./renders/RenderQueuePanel";
9
9
  import { SlideshowPanel } from "./panels/SlideshowPanel";
10
10
  import { VariablesPanel } from "./panels/VariablesPanel";
11
+ import { MotionBlurPanel } from "./panels/MotionBlurPanel";
11
12
  import { Dock } from "./dock/Dock";
12
13
  import { useDockLayoutStore } from "./dock/dockLayoutStore";
13
14
  import type { RenderJob } from "./renders/useRenderQueue";
@@ -300,6 +301,14 @@ export const StudioRightPanels = memo(function StudioRightPanels({
300
301
  recordEdit={recordEdit}
301
302
  />
302
303
  </Dock.Panel>
304
+ <Dock.Panel id="motionBlur">
305
+ <MotionBlurPanel
306
+ sdkSession={sdkSession}
307
+ publishSdkSession={publishSdkSession}
308
+ reloadPreview={reloadPreview}
309
+ recordEdit={recordEdit}
310
+ />
311
+ </Dock.Panel>
303
312
  <Dock.Panel id="slideshow">
304
313
  <SlideshowPanel
305
314
  scenes={slideshowScenes}
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useSyncExternalStore } from "react";
2
2
  import {
3
+ Aperture,
3
4
  BracketsCurly,
4
5
  ChartBarHorizontal,
5
6
  Code,
@@ -29,6 +30,7 @@ const TAB_ICONS: Record<PanelId, Icon> = {
29
30
  renders: FilmSlate,
30
31
  variables: BracketsCurly,
31
32
  slideshow: Presentation,
33
+ motionBlur: Aperture,
32
34
  };
33
35
 
34
36
  /**
@@ -158,9 +158,9 @@ describe("dock tab strips", () => {
158
158
  it("jump to the first and last tab with Home and End", () => {
159
159
  const tabs = tabsOf("design");
160
160
  press(tabs[0] as HTMLElement, "End");
161
- expect(api.getPanel("design")?.group.activePanel?.id).toBe("variables");
162
- expect(document.activeElement).toBe(tabs[3]);
163
- press(tabs[3] as HTMLElement, "Home");
161
+ expect(api.getPanel("design")?.group.activePanel?.id).toBe("motionBlur");
162
+ expect(document.activeElement).toBe(tabs[4]);
163
+ press(tabs[4] as HTMLElement, "Home");
164
164
  expect(api.getPanel("design")?.group.activePanel?.id).toBe("design");
165
165
  });
166
166
  });
@@ -9,7 +9,7 @@ const MIN_SIDE_W = 200;
9
9
  const MIN_SIDE_W_FLOOR = 120;
10
10
  const DEFAULT_TIMELINE_H = 360;
11
11
  const DEFAULT_LEFT = ["compositions", "assets", "code", "catalog"] as const;
12
- const DEFAULT_RIGHT = ["design", "layers", "renders", "variables"] as const;
12
+ const DEFAULT_RIGHT = ["design", "layers", "renders", "variables", "motionBlur"] as const;
13
13
 
14
14
  /** Preferred side widths; when they overflow the preview's floor the right yields first, then the left. */
15
15
  export function defaultSideWidths(viewportWidth: number) {
@@ -12,6 +12,7 @@ export const PANEL_IDS = [
12
12
  "renders",
13
13
  "variables",
14
14
  "slideshow",
15
+ "motionBlur",
15
16
  ] as const;
16
17
 
17
18
  export type PanelId = (typeof PANEL_IDS)[number];
@@ -56,6 +57,11 @@ export const PANEL_DEFINITIONS = {
56
57
  renders: { title: "Renders", zone: "right", reopen: { near: "design", direction: "within" } },
57
58
  variables: { title: "Variables", zone: "right", reopen: { near: "design", direction: "within" } },
58
59
  slideshow: { title: "Slideshow", zone: "right", reopen: { near: "design", direction: "within" } },
60
+ motionBlur: {
61
+ title: "Blur",
62
+ zone: "right",
63
+ reopen: { near: "design", direction: "within" },
64
+ },
59
65
  } as const satisfies Record<PanelId, PanelDefinition>;
60
66
 
61
67
  export function isPanelId(value: unknown): value is PanelId {