@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.
- package/dist/assets/{hyperframes-player-0HXJPmYC.js → hyperframes-player-ChN3cOr0.js} +3 -3
- package/dist/assets/{index-DsSfhjUS.js → index-BYbFcRR0.js} +1 -1
- package/dist/assets/{index-BYw97F07.js → index-CHu83hHM.js} +126 -125
- package/dist/assets/{index-B3vk3hfU.css → index-CYbvOVp-.css} +1 -1
- package/dist/{chunk-563JPIL4.js → chunk-6SXUUBPD.js} +9 -3
- package/dist/{chunk-FF5DM7BQ.js → chunk-7HZGKFFU.js} +18 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.html +2 -2
- package/dist/index.js +509 -282
- 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.dom.test.ts +126 -0
- package/src/captions/parser.test.ts +71 -1
- package/src/captions/parser.ts +121 -65
- package/src/components/StudioRightPanels.tsx +9 -0
- package/src/components/dock/DockTab.tsx +2 -0
- package/src/components/dock/dockAccessibility.test.ts +3 -3
- package/src/components/dock/dockLayout.ts +1 -1
- package/src/components/dock/panelRegistry.ts +6 -0
- package/src/components/panels/MotionBlurPanel.test.tsx +177 -0
- package/src/components/panels/MotionBlurPanel.tsx +163 -0
- package/src/components/panels/motionBlurPanelModel.test.ts +143 -0
- package/src/components/panels/motionBlurPanelModel.ts +98 -0
- package/src/components/ui/Tooltip.test.tsx +32 -0
- package/src/components/ui/Tooltip.tsx +23 -3
- package/src/hooks/usePanelLayout.test.ts +1 -1
- package/src/player/components/TimelineClip.filmstrip.test.tsx +1 -1
- package/src/player/components/timelineMotionStyles.test.ts +1 -1
- package/src/styles/theme.css +1 -1
- package/src/utils/studioHelpers.ts +2 -1
- package/src/utils/studioUrlState.ts +8 -1
package/dist/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/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/shader-transitions": "0.8.
|
|
67
|
-
"@hyperframes/studio-server": "0.8.
|
|
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",
|
|
@@ -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 = `
|
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,
|
|
@@ -38,23 +46,32 @@ export function buildCaptionModel(
|
|
|
38
46
|
transcript: TranscriptWord[],
|
|
39
47
|
options: BuildOptions,
|
|
40
48
|
): CaptionModel {
|
|
41
|
-
const {
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
70
|
+
const segmentIndex = segments.size;
|
|
71
|
+
const segmentId = `segment-${segmentIndex}`;
|
|
55
72
|
const segment: CaptionSegment = {
|
|
56
73
|
id: segmentId,
|
|
57
|
-
wordId: word.id ?? `w${
|
|
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
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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(
|
|
153
|
-
const wordEls = iframeDoc.querySelectorAll(
|
|
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
|
-
//
|
|
165
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
349
|
+
const items: unknown[] = parsed;
|
|
350
|
+
for (const item of items) {
|
|
297
351
|
if (
|
|
298
352
|
item !== null &&
|
|
299
353
|
typeof item === "object" &&
|
|
300
|
-
|
|
301
|
-
typeof
|
|
302
|
-
|
|
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
|
|
307
|
-
text:
|
|
308
|
-
start:
|
|
309
|
-
end:
|
|
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("
|
|
162
|
-
expect(document.activeElement).toBe(tabs[
|
|
163
|
-
press(tabs[
|
|
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 {
|