@hyperframes/studio 0.8.191 → 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.
- package/dist/assets/{hyperframes-player-Bv9hQ3v7.js → hyperframes-player-ChN3cOr0.js} +3 -3
- package/dist/assets/{index-Df14bh-L.js → index-BYbFcRR0.js} +1 -1
- package/dist/assets/{index-D_QqfWhx.js → index-CHu83hHM.js} +122 -122
- package/dist/assets/{index-CBO8uz7-.css → index-CYbvOVp-.css} +1 -1
- package/dist/{chunk-IWO4UENE.js → chunk-6SXUUBPD.js} +1 -1
- package/dist/{chunk-FF5DM7BQ.js → chunk-7HZGKFFU.js} +18 -4
- package/dist/index.html +2 -2
- package/dist/index.js +53 -26
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.d.ts +3 -1
- package/dist/ui.js +1 -1
- package/package.json +9 -8
- package/src/captions/parser.test.ts +71 -1
- package/src/captions/parser.ts +76 -42
- package/src/components/ui/Tooltip.test.tsx +32 -0
- package/src/components/ui/Tooltip.tsx +23 -3
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
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.
|
|
63
|
-
"@hyperframes/parsers": "0.8.
|
|
64
|
-
"@hyperframes/player": "0.8.
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
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.
|
|
85
|
+
"@hyperframes/producer": "0.8.192"
|
|
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 = `
|
package/src/captions/parser.ts
CHANGED
|
@@ -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
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
349
|
+
const items: unknown[] = parsed;
|
|
350
|
+
for (const item of items) {
|
|
319
351
|
if (
|
|
320
352
|
item !== null &&
|
|
321
353
|
typeof item === "object" &&
|
|
322
|
-
|
|
323
|
-
typeof
|
|
324
|
-
|
|
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
|
|
329
|
-
text:
|
|
330
|
-
start:
|
|
331
|
-
end:
|
|
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 {
|
|
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
|
|
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
|
-
{
|
|
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>
|