@hyperframes/studio 0.8.191 → 0.8.193

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/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.191",
3
+ "version": "0.8.193",
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.191",
63
- "@hyperframes/parsers": "0.8.191",
64
- "@hyperframes/player": "0.8.191",
65
- "@hyperframes/shader-transitions": "0.8.191",
66
- "@hyperframes/studio-server": "0.8.191",
67
- "@hyperframes/sdk": "0.8.191"
63
+ "@hyperframes/parsers": "0.8.193",
64
+ "@hyperframes/sdk": "0.8.193",
65
+ "@hyperframes/core": "0.8.193",
66
+ "@hyperframes/shader-transitions": "0.8.193",
67
+ "@hyperframes/studio-server": "0.8.193",
68
+ "@hyperframes/player": "0.8.193"
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.191"
85
+ "@hyperframes/producer": "0.8.193"
85
86
  },
86
87
  "peerDependencies": {
87
88
  "react": "19",
@@ -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,
@@ -129,24 +137,26 @@ function readTranscriptGroups(transcript: TranscriptWord[], groupEls: NodeListOf
129
137
  * Looks for `const TRANSCRIPT = [...]` or `const script = [...]` (also let/var)
130
138
  * and parses each `{ text, start, end }` object into TranscriptWord objects.
131
139
  *
140
+ * Supports static literals only; expressions are never evaluated.
132
141
  * Returns an empty array if no transcript is found or if parsing fails.
133
142
  */
134
143
  export function extractTranscript(source: string): TranscriptWord[] {
135
- // Match: (const|let|var) (TRANSCRIPT|script) = [...]
136
- // The array may span multiple lines and contain trailing commas.
137
- // The lazy [\s\S]*? anchors on the first `];` — assumes transcript word
138
- // text never contains a literal `];` string (safe for speech transcripts).
139
- const varPattern = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*(\[[\s\S]*?\]);/;
140
- 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);
141
148
 
142
149
  if (!match) {
143
150
  return [];
144
151
  }
145
152
 
146
- const arrayLiteral = match[1];
147
-
148
153
  try {
149
- 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));
150
160
  } catch {
151
161
  return [];
152
162
  }
@@ -284,51 +294,75 @@ export function parseCaptionComposition(
284
294
  return model;
285
295
  }
286
296
 
287
- /**
288
- * Parses a JS array literal containing `{ text, start, end }` objects.
289
- *
290
- * Handles:
291
- * - Double-quoted and single-quoted string values
292
- * - Trailing commas after the last element or property
293
- * - Unquoted property keys (standard JS object literal syntax)
294
- * - Numeric values for start/end
295
- */
296
- function parseTranscriptArray(arrayLiteral: string): TranscriptWord[] {
297
- // Try parsing as-is first (handles already-valid JSON)
298
- let parsed: unknown;
299
- try {
300
- parsed = JSON.parse(arrayLiteral);
301
- } catch {
302
- // Not valid JSON — normalize single quotes, unquoted keys, trailing commas
303
- let normalized = arrayLiteral;
304
- normalized = normalized.replace(/'((?:[^'\\]|\\.)*)'/g, (_match, inner) => {
305
- const escaped = inner.replace(/\\'/g, "'").replace(/"/g, '\\"');
306
- return `"${escaped}"`;
307
- });
308
- normalized = normalized.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":');
309
- normalized = normalized.replace(/,(\s*[}\]])/g, "$1");
310
- 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));
311
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");
339
+ }
340
+ return [key, readStaticValue(property.value)];
341
+ }
312
342
 
343
+ function parseTranscriptArray(parsed: unknown): TranscriptWord[] {
313
344
  if (!Array.isArray(parsed)) {
314
345
  return [];
315
346
  }
316
347
 
317
348
  const words: TranscriptWord[] = [];
318
- for (const item of parsed) {
349
+ const items: unknown[] = parsed;
350
+ for (const item of items) {
319
351
  if (
320
352
  item !== null &&
321
353
  typeof item === "object" &&
322
- typeof (item as Record<string, unknown>).text === "string" &&
323
- typeof (item as Record<string, unknown>).start === "number" &&
324
- 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"
325
360
  ) {
326
- const entry = item as Record<string, unknown>;
327
361
  words.push({
328
- ...(typeof entry.id === "string" ? { id: entry.id } : {}),
329
- text: entry.text as string,
330
- start: entry.start as number,
331
- 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,
332
366
  });
333
367
  }
334
368
  }
@@ -108,3 +108,35 @@ it("wraps its child in a box-less span so a disabled child still gets hover", ()
108
108
  expect(trigger.parentElement?.className).toBe("contents");
109
109
  expect(trigger.parentElement?.parentElement?.parentElement).toBe(document.body);
110
110
  });
111
+
112
+ it("keeps a label on one line unless it is given lines", async () => {
113
+ const trigger = mount();
114
+ act(() => trigger.focus());
115
+ await settle();
116
+
117
+ expect(bubble()?.className).toContain("whitespace-nowrap");
118
+ expect(bubble()?.querySelector("span")).toBeNull();
119
+ });
120
+
121
+ it("wraps a long label at a reading width and cuts it at its lines", async () => {
122
+ const label = "Make a 30 second launch promo for the spring release. ".repeat(6).trim();
123
+ const host = mountHost(
124
+ <Tooltip label={label} delay={0} lines={3}>
125
+ <button type="button" data-testid="long">
126
+ P
127
+ </button>
128
+ </Tooltip>,
129
+ );
130
+ act(() => host.querySelector<HTMLElement>('[data-testid="long"]')?.focus());
131
+ await settle();
132
+
133
+ const tip = bubble();
134
+ expect(tip?.className).toContain("whitespace-normal");
135
+ expect(tip?.className, "a long URL or path breaks too").toContain("break-words");
136
+ expect(tip?.className).toContain("max-w-[300px]");
137
+ expect(tip?.className).not.toContain("whitespace-nowrap");
138
+ const text = tip?.querySelector<HTMLElement>("span");
139
+ expect(text?.textContent).toBe(label);
140
+ expect(text?.className).toBe("line-clamp-(--tooltip-lines)");
141
+ expect(text?.style.getPropertyValue("--tooltip-lines")).toBe("3");
142
+ });
@@ -4,7 +4,14 @@
4
4
  */
5
5
 
6
6
  import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
7
- import { cloneElement, useId, useRef, useState, type ReactElement } from "react";
7
+ import {
8
+ cloneElement,
9
+ useId,
10
+ useRef,
11
+ useState,
12
+ type CSSProperties,
13
+ type ReactElement,
14
+ } from "react";
8
15
  import { cn } from "./cn";
9
16
  import { floatingMotion, POPUP_LAYER } from "./Menu";
10
17
 
@@ -16,6 +23,8 @@ interface TooltipProps {
16
23
  delay?: number;
17
24
  disabled?: boolean;
18
25
  side?: "top" | "bottom" | "left" | "right";
26
+ /** Wraps a long label at a reading width and cuts it at this many lines with an ellipsis. Unset, one line. */
27
+ lines?: number;
19
28
  }
20
29
 
21
30
  /** Matches the old bubble's gap from its trigger, and its viewport margin. */
@@ -28,6 +37,7 @@ export function Tooltip({
28
37
  delay = 400,
29
38
  side = "top",
30
39
  disabled = false,
40
+ lines,
31
41
  }: TooltipProps) {
32
42
  const [open, setOpen] = useState(false);
33
43
  const boxRef = useRef<HTMLSpanElement>(null);
@@ -52,11 +62,21 @@ export function Tooltip({
52
62
  id={tooltipId}
53
63
  role="tooltip"
54
64
  className={cn(
55
- "pointer-events-none rounded-md border border-border-input bg-surface px-2 py-1 text-step-10 font-medium text-text-1 whitespace-nowrap shadow-menu",
65
+ "pointer-events-none rounded-md border border-border-input bg-surface px-2 py-1 text-step-10 font-medium text-text-1 shadow-menu",
66
+ lines ? "max-w-[300px] whitespace-normal break-words" : "whitespace-nowrap",
56
67
  floatingMotion("duration-tooltip"),
57
68
  )}
58
69
  >
59
- {label}
70
+ {lines ? (
71
+ <span
72
+ className="line-clamp-(--tooltip-lines)"
73
+ style={{ "--tooltip-lines": lines } as CSSProperties}
74
+ >
75
+ {label}
76
+ </span>
77
+ ) : (
78
+ label
79
+ )}
60
80
  </BaseTooltip.Popup>
61
81
  </BaseTooltip.Positioner>
62
82
  </BaseTooltip.Portal>
@@ -98,6 +98,28 @@ describe("sceneSwapFor", () => {
98
98
  expect(swap).not.toHaveBeenCalled();
99
99
  });
100
100
 
101
+ it("leaves no listener on the caller's cancel signal once swaps settle, so a swapped film can be freed", async () => {
102
+ vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("<html></html>"));
103
+ const cancel = new AbortController();
104
+ const live = new Set<unknown>();
105
+ const { signal } = cancel;
106
+ const add = signal.addEventListener.bind(signal);
107
+ const remove = signal.removeEventListener.bind(signal);
108
+ signal.addEventListener = ((type: string, listener: EventListener, options?: unknown) => {
109
+ live.add(listener);
110
+ add(type, listener, options as AddEventListenerOptions);
111
+ }) as AbortSignal["addEventListener"];
112
+ signal.removeEventListener = ((type: string, listener: EventListener, options?: unknown) => {
113
+ live.delete(listener);
114
+ remove(type, listener, options as EventListenerOptions);
115
+ }) as AbortSignal["removeEventListener"];
116
+ const { iframe } = swappableIframe();
117
+ const refused = swappableIframe(vi.fn(async () => Promise.reject(new Error("refused")))).iframe;
118
+ for (let n = 0; n < 3; n++) await sceneSwapFor(iframe)!("/preview", () => true, signal);
119
+ await expect(sceneSwapFor(refused)!("/preview", () => true, signal)).rejects.toThrow("refused");
120
+ expect(live.size).toBe(0);
121
+ });
122
+
101
123
  it("swaps a document that arrives in time", async () => {
102
124
  vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("<html></html>"));
103
125
  const { iframe, swap } = swappableIframe();
@@ -88,7 +88,8 @@ export function sceneSwapFor(
88
88
  () => deadline.abort(new Error("the preview took too long to swap")),
89
89
  SCENE_SWAP_MS,
90
90
  );
91
- cancel?.addEventListener("abort", () => deadline.abort(cancel.reason), { once: true });
91
+ const onCancel = () => deadline.abort(cancel?.reason);
92
+ cancel?.addEventListener("abort", onCancel, { once: true });
92
93
  const swapping = (async () => {
93
94
  const response = await studioApiFetch(url, { signal: deadline.signal });
94
95
  if (!response.ok) throw new Error(`preview request failed with ${response.status}`);
@@ -104,6 +105,7 @@ export function sceneSwapFor(
104
105
  await Promise.race([swapping, expired]);
105
106
  } finally {
106
107
  clearTimeout(timer);
108
+ cancel?.removeEventListener("abort", onCancel);
107
109
  }
108
110
  iframe.dispatchEvent(new Event(SCENES_SWAPPED));
109
111
  };