@hyperframes/studio 0.2.0 → 0.2.2-alpha.1

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.
@@ -0,0 +1,314 @@
1
+ // Caption Parser — Extract Transcript & Build Caption Model
2
+ // Parses a caption composition's JavaScript source to extract the transcript word array,
3
+ // and builds a CaptionModel from a TranscriptWord array.
4
+
5
+ import {
6
+ CaptionModel,
7
+ CaptionSegment,
8
+ CaptionGroup,
9
+ CaptionStyle,
10
+ CaptionContainerStyle,
11
+ DEFAULT_STYLE,
12
+ DEFAULT_CONTAINER,
13
+ DEFAULT_ANIMATION_SET,
14
+ } from "./types.js";
15
+
16
+ export interface TranscriptWord {
17
+ id?: string;
18
+ text: string;
19
+ start: number;
20
+ end: number;
21
+ }
22
+
23
+ export interface BuildOptions {
24
+ width: number;
25
+ height: number;
26
+ duration: number;
27
+ wordsPerGroup?: number; // default 5
28
+ }
29
+
30
+ /**
31
+ * Builds a CaptionModel from a transcript word array and composition dimensions.
32
+ *
33
+ * Words are grouped into chunks of `wordsPerGroup` (default 5). Each word becomes a
34
+ * CaptionSegment with its original timing. Each chunk becomes a CaptionGroup with
35
+ * DEFAULT_STYLE, DEFAULT_ANIMATION_SET, and DEFAULT_CONTAINER.
36
+ */
37
+ export function buildCaptionModel(
38
+ transcript: TranscriptWord[],
39
+ options: BuildOptions,
40
+ ): CaptionModel {
41
+ const { width, height, duration, wordsPerGroup = 5 } = options;
42
+
43
+ const segments = new Map<string, CaptionSegment>();
44
+ const groups = new Map<string, CaptionGroup>();
45
+ const groupOrder: string[] = [];
46
+
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}`;
51
+ const segmentIds: string[] = [];
52
+
53
+ chunk.forEach((word, wordIdx) => {
54
+ const segmentId = `segment-${groupIdx + wordIdx}`;
55
+ const segment: CaptionSegment = {
56
+ id: segmentId,
57
+ wordId: word.id ?? `w${groupIdx + wordIdx}`,
58
+ text: word.text,
59
+ start: word.start,
60
+ end: word.end,
61
+ groupIndex: wordIdx,
62
+ style: {},
63
+ animation: {},
64
+ };
65
+ segments.set(segmentId, segment);
66
+ segmentIds.push(segmentId);
67
+ });
68
+
69
+ const group: CaptionGroup = {
70
+ id: groupId,
71
+ segmentIds,
72
+ style: { ...DEFAULT_STYLE },
73
+ animation: {
74
+ entrance: { ...DEFAULT_ANIMATION_SET.entrance },
75
+ highlight: DEFAULT_ANIMATION_SET.highlight,
76
+ exit: { ...DEFAULT_ANIMATION_SET.exit },
77
+ },
78
+ containerStyle: { ...DEFAULT_CONTAINER },
79
+ };
80
+ groups.set(groupId, group);
81
+ groupOrder.push(groupId);
82
+ }
83
+
84
+ return {
85
+ width,
86
+ height,
87
+ duration,
88
+ segments,
89
+ groups,
90
+ groupOrder,
91
+ defaultAnimation: {
92
+ entrance: { ...DEFAULT_ANIMATION_SET.entrance },
93
+ highlight: DEFAULT_ANIMATION_SET.highlight,
94
+ exit: { ...DEFAULT_ANIMATION_SET.exit },
95
+ },
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Extracts a transcript word array from caption composition source code.
101
+ *
102
+ * Looks for `const TRANSCRIPT = [...]` or `const script = [...]` (also let/var)
103
+ * and parses each `{ text, start, end }` object into TranscriptWord objects.
104
+ *
105
+ * Returns an empty array if no transcript is found or if parsing fails.
106
+ */
107
+ 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);
114
+
115
+ if (!match) {
116
+ return [];
117
+ }
118
+
119
+ const arrayLiteral = match[1];
120
+
121
+ try {
122
+ return parseTranscriptArray(arrayLiteral);
123
+ } catch {
124
+ return [];
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Parses a caption composition from a live iframe DOM, extracting the transcript
130
+ * from the source and reading computed styles from rendered elements.
131
+ *
132
+ * Runs in the Studio (outside the iframe). Reads computed styles from iframe DOM
133
+ * elements to build a fully-styled CaptionModel.
134
+ *
135
+ * Returns null if no transcript is found in the source.
136
+ */
137
+ export function parseCaptionComposition(
138
+ iframeDoc: Document,
139
+ iframeWin: Window,
140
+ source: string,
141
+ compositionWidth: number,
142
+ compositionHeight: number,
143
+ compositionDuration: number,
144
+ ): CaptionModel | null {
145
+ // Step 1: Extract transcript words from source
146
+ const transcript = extractTranscript(source);
147
+ if (transcript.length === 0) {
148
+ return null;
149
+ }
150
+
151
+ // 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
+ }
163
+
164
+ // Step 4: Build the caption model with inferred grouping
165
+ const model = buildCaptionModel(transcript, {
166
+ width: compositionWidth,
167
+ height: compositionHeight,
168
+ duration: compositionDuration,
169
+ wordsPerGroup,
170
+ });
171
+
172
+ // Step 5: Read computed styles from the first word or group element
173
+ const firstWordEl = wordEls.item(0) as Element | null;
174
+ const firstGroupEl = groupEls.item(0) as Element | null;
175
+ const styleSourceEl = firstWordEl ?? firstGroupEl;
176
+
177
+ if (styleSourceEl) {
178
+ const computed = iframeWin.getComputedStyle(styleSourceEl);
179
+
180
+ // Build partial style overrides from computed values
181
+ const styleOverrides: Partial<CaptionStyle> = {};
182
+
183
+ const fontSize = parseFloat(computed.fontSize);
184
+ if (!isNaN(fontSize) && fontSize > 0) {
185
+ styleOverrides.fontSize = fontSize;
186
+ }
187
+
188
+ const fontWeight = computed.fontWeight;
189
+ if (fontWeight) {
190
+ const numericWeight = parseInt(fontWeight, 10);
191
+ styleOverrides.fontWeight = isNaN(numericWeight) ? fontWeight : numericWeight;
192
+ }
193
+
194
+ const fontFamily = computed.fontFamily;
195
+ if (fontFamily) {
196
+ styleOverrides.fontFamily = fontFamily;
197
+ }
198
+
199
+ const color = computed.color;
200
+ if (color) {
201
+ styleOverrides.color = color;
202
+ }
203
+
204
+ const textTransform = computed.textTransform as CaptionStyle["textTransform"];
205
+ if (
206
+ textTransform === "none" ||
207
+ textTransform === "uppercase" ||
208
+ textTransform === "lowercase" ||
209
+ textTransform === "capitalize"
210
+ ) {
211
+ styleOverrides.textTransform = textTransform;
212
+ }
213
+
214
+ const letterSpacing = computed.letterSpacing;
215
+ if (letterSpacing && letterSpacing !== "normal") {
216
+ const lsPx = parseFloat(letterSpacing);
217
+ const fsPx = styleOverrides.fontSize ?? DEFAULT_STYLE.fontSize;
218
+ if (!isNaN(lsPx) && fsPx > 0) {
219
+ // Convert px to em
220
+ styleOverrides.letterSpacing = lsPx / fsPx;
221
+ }
222
+ }
223
+
224
+ // Step 6: Read container styles from group element (if visible background)
225
+ const containerOverrides: Partial<CaptionContainerStyle> = {};
226
+
227
+ if (firstGroupEl) {
228
+ const groupComputed = iframeWin.getComputedStyle(firstGroupEl);
229
+ const bgColor = groupComputed.backgroundColor;
230
+ // Only apply if it's not transparent/none
231
+ if (bgColor && bgColor !== "rgba(0, 0, 0, 0)" && bgColor !== "transparent") {
232
+ containerOverrides.backgroundColor = bgColor;
233
+ containerOverrides.backgroundOpacity = 1;
234
+ }
235
+
236
+ const borderRadius = parseFloat(groupComputed.borderRadius);
237
+ if (!isNaN(borderRadius) && borderRadius > 0) {
238
+ containerOverrides.borderRadius = borderRadius;
239
+ }
240
+
241
+ // Parse padding shorthand or individual values
242
+ const paddingTop = parseFloat(groupComputed.paddingTop);
243
+ const paddingRight = parseFloat(groupComputed.paddingRight);
244
+ const paddingBottom = parseFloat(groupComputed.paddingBottom);
245
+ const paddingLeft = parseFloat(groupComputed.paddingLeft);
246
+ if (!isNaN(paddingTop)) containerOverrides.paddingTop = paddingTop;
247
+ if (!isNaN(paddingRight)) containerOverrides.paddingRight = paddingRight;
248
+ if (!isNaN(paddingBottom)) containerOverrides.paddingBottom = paddingBottom;
249
+ if (!isNaN(paddingLeft)) containerOverrides.paddingLeft = paddingLeft;
250
+ }
251
+
252
+ // Step 7: Apply extracted styles to all groups in the model
253
+ for (const groupId of model.groupOrder) {
254
+ const group = model.groups.get(groupId);
255
+ if (!group) continue;
256
+
257
+ group.style = { ...group.style, ...styleOverrides };
258
+ group.containerStyle = { ...group.containerStyle, ...containerOverrides };
259
+ }
260
+ }
261
+
262
+ return model;
263
+ }
264
+
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);
289
+ }
290
+
291
+ if (!Array.isArray(parsed)) {
292
+ return [];
293
+ }
294
+
295
+ const words: TranscriptWord[] = [];
296
+ for (const item of parsed) {
297
+ if (
298
+ item !== null &&
299
+ 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"
303
+ ) {
304
+ const entry = item as Record<string, unknown>;
305
+ words.push({
306
+ text: entry.text as string,
307
+ start: entry.start as number,
308
+ end: entry.end as number,
309
+ });
310
+ }
311
+ }
312
+
313
+ return words;
314
+ }
@@ -0,0 +1,272 @@
1
+ import { create } from "zustand";
2
+ import {
3
+ CaptionAnimation,
4
+ CaptionAnimationSet,
5
+ CaptionContainerStyle,
6
+ CaptionModel,
7
+ CaptionStyle,
8
+ } from "./types";
9
+
10
+ let nextSplitId = 0;
11
+
12
+ interface CaptionState {
13
+ isEditMode: boolean;
14
+ model: CaptionModel | null;
15
+ selectedSegmentIds: Set<string>;
16
+ selectedGroupId: string | null;
17
+ sourceFilePath: string | null;
18
+
19
+ // Basic
20
+ setEditMode: (active: boolean) => void;
21
+ setModel: (model: CaptionModel | null) => void;
22
+ setSourceFilePath: (path: string | null) => void;
23
+
24
+ // Selection
25
+ selectSegment: (id: string, additive?: boolean) => void;
26
+ selectGroup: (id: string) => void;
27
+ selectAll: () => void;
28
+ clearSelection: () => void;
29
+
30
+ // Segment mutations
31
+ updateSegmentStyle: (segmentId: string, style: Partial<CaptionStyle>) => void;
32
+ updateSegmentText: (segmentId: string, text: string) => void;
33
+ updateSegmentTiming: (segmentId: string, start: number, end: number) => void;
34
+
35
+ // Group mutations
36
+ updateGroupStyle: (groupId: string, style: Partial<CaptionStyle>) => void;
37
+ updateGroupContainer: (groupId: string, container: Partial<CaptionContainerStyle>) => void;
38
+ updateGroupAnimation: (
39
+ groupId: string,
40
+ phase: keyof CaptionAnimationSet,
41
+ animation: Partial<CaptionAnimation>,
42
+ ) => void;
43
+ splitGroup: (groupId: string, atSegmentId: string) => void;
44
+ mergeGroups: (groupId1: string, groupId2: string) => void;
45
+
46
+ // Bulk
47
+ updateSelectedStyle: (style: Partial<CaptionStyle>) => void;
48
+ applyAnimationToAll: (animation: CaptionAnimationSet) => void;
49
+
50
+ // Reset
51
+ reset: () => void;
52
+ }
53
+
54
+ const initialState = {
55
+ isEditMode: false,
56
+ model: null,
57
+ selectedSegmentIds: new Set<string>(),
58
+ selectedGroupId: null,
59
+ sourceFilePath: null,
60
+ };
61
+
62
+ export const useCaptionStore = create<CaptionState>((set) => ({
63
+ ...initialState,
64
+
65
+ // Basic
66
+ setEditMode: (active) => set({ isEditMode: active }),
67
+ setModel: (model) => set({ model }),
68
+ setSourceFilePath: (path) => set({ sourceFilePath: path }),
69
+
70
+ // Selection
71
+ selectSegment: (id, additive = false) =>
72
+ set((state) => {
73
+ if (additive) {
74
+ const next = new Set(state.selectedSegmentIds);
75
+ if (next.has(id)) {
76
+ next.delete(id);
77
+ } else {
78
+ next.add(id);
79
+ }
80
+ return { selectedSegmentIds: next, selectedGroupId: null };
81
+ }
82
+ return { selectedSegmentIds: new Set([id]), selectedGroupId: null };
83
+ }),
84
+
85
+ selectGroup: (id) =>
86
+ set((state) => {
87
+ const group = state.model?.groups.get(id);
88
+ if (!group) return {};
89
+ return {
90
+ selectedSegmentIds: new Set(group.segmentIds),
91
+ selectedGroupId: id,
92
+ };
93
+ }),
94
+
95
+ selectAll: () =>
96
+ set((state) => {
97
+ if (!state.model) return {};
98
+ return {
99
+ selectedSegmentIds: new Set(state.model.segments.keys()),
100
+ selectedGroupId: null,
101
+ };
102
+ }),
103
+
104
+ clearSelection: () => set({ selectedSegmentIds: new Set(), selectedGroupId: null }),
105
+
106
+ // Segment mutations
107
+ updateSegmentStyle: (segmentId, style) =>
108
+ set((state) => {
109
+ if (!state.model) return {};
110
+ const segment = state.model.segments.get(segmentId);
111
+ if (!segment) return {};
112
+ const segments = new Map(state.model.segments);
113
+ segments.set(segmentId, { ...segment, style: { ...segment.style, ...style } });
114
+ return { model: { ...state.model, segments } };
115
+ }),
116
+
117
+ updateSegmentText: (segmentId, text) =>
118
+ set((state) => {
119
+ if (!state.model) return {};
120
+ const segment = state.model.segments.get(segmentId);
121
+ if (!segment) return {};
122
+ const segments = new Map(state.model.segments);
123
+ segments.set(segmentId, { ...segment, text });
124
+ return { model: { ...state.model, segments } };
125
+ }),
126
+
127
+ updateSegmentTiming: (segmentId, start, end) =>
128
+ set((state) => {
129
+ if (!state.model) return {};
130
+ const segment = state.model.segments.get(segmentId);
131
+ if (!segment) return {};
132
+ const segments = new Map(state.model.segments);
133
+ segments.set(segmentId, { ...segment, start, end });
134
+ return { model: { ...state.model, segments } };
135
+ }),
136
+
137
+ // Group mutations
138
+ updateGroupStyle: (groupId, style) =>
139
+ set((state) => {
140
+ if (!state.model) return {};
141
+ const group = state.model.groups.get(groupId);
142
+ if (!group) return {};
143
+ const groups = new Map(state.model.groups);
144
+ groups.set(groupId, { ...group, style: { ...group.style, ...style } });
145
+ return { model: { ...state.model, groups } };
146
+ }),
147
+
148
+ updateGroupContainer: (groupId, container) =>
149
+ set((state) => {
150
+ if (!state.model) return {};
151
+ const group = state.model.groups.get(groupId);
152
+ if (!group) return {};
153
+ const groups = new Map(state.model.groups);
154
+ groups.set(groupId, {
155
+ ...group,
156
+ containerStyle: { ...group.containerStyle, ...container },
157
+ });
158
+ return { model: { ...state.model, groups } };
159
+ }),
160
+
161
+ updateGroupAnimation: (groupId, phase, animation) =>
162
+ set((state) => {
163
+ if (!state.model) return {};
164
+ const group = state.model.groups.get(groupId);
165
+ if (!group) return {};
166
+ const groups = new Map(state.model.groups);
167
+ const existingPhase = group.animation[phase];
168
+ const mergedPhase =
169
+ existingPhase !== null
170
+ ? { ...existingPhase, ...animation }
171
+ : (animation as CaptionAnimation);
172
+ groups.set(groupId, {
173
+ ...group,
174
+ animation: { ...group.animation, [phase]: mergedPhase },
175
+ });
176
+ return { model: { ...state.model, groups } };
177
+ }),
178
+
179
+ splitGroup: (groupId, atSegmentId) =>
180
+ set((state) => {
181
+ if (!state.model) return {};
182
+ const group = state.model.groups.get(groupId);
183
+ if (!group) return {};
184
+
185
+ const splitIndex = group.segmentIds.indexOf(atSegmentId);
186
+ if (splitIndex <= 0) return {};
187
+
188
+ const firstIds = group.segmentIds.slice(0, splitIndex);
189
+ const secondIds = group.segmentIds.slice(splitIndex);
190
+
191
+ const newGroupId = `group-split-${nextSplitId++}`;
192
+ const groups = new Map(state.model.groups);
193
+ groups.set(groupId, { ...group, segmentIds: firstIds });
194
+ groups.set(newGroupId, { ...group, id: newGroupId, segmentIds: secondIds });
195
+
196
+ const orderIndex = state.model.groupOrder.indexOf(groupId);
197
+ const groupOrder = [...state.model.groupOrder];
198
+ groupOrder.splice(orderIndex + 1, 0, newGroupId);
199
+
200
+ // Update groupIndex for segments in the new second group
201
+ const segments = new Map(state.model.segments);
202
+ secondIds.forEach((segId, idx) => {
203
+ const seg = segments.get(segId);
204
+ if (seg) {
205
+ segments.set(segId, { ...seg, groupIndex: idx });
206
+ }
207
+ });
208
+
209
+ return { model: { ...state.model, groups, segments, groupOrder } };
210
+ }),
211
+
212
+ mergeGroups: (groupId1, groupId2) =>
213
+ set((state) => {
214
+ if (!state.model) return {};
215
+ const group1 = state.model.groups.get(groupId1);
216
+ const group2 = state.model.groups.get(groupId2);
217
+ if (!group1 || !group2) return {};
218
+
219
+ const mergedSegmentIds = [...group1.segmentIds, ...group2.segmentIds];
220
+
221
+ const groups = new Map(state.model.groups);
222
+ groups.set(groupId1, { ...group1, segmentIds: mergedSegmentIds });
223
+ groups.delete(groupId2);
224
+
225
+ const groupOrder = state.model.groupOrder.filter((id) => id !== groupId2);
226
+
227
+ // Update groupIndex for segments from group2
228
+ const segments = new Map(state.model.segments);
229
+ group2.segmentIds.forEach((segId, idx) => {
230
+ const seg = segments.get(segId);
231
+ if (seg) {
232
+ segments.set(segId, { ...seg, groupIndex: group1.segmentIds.length + idx });
233
+ }
234
+ });
235
+
236
+ // Clear selection if it referenced group2
237
+ const selectedGroupId = state.selectedGroupId === groupId2 ? null : state.selectedGroupId;
238
+
239
+ return { model: { ...state.model, groups, segments, groupOrder }, selectedGroupId };
240
+ }),
241
+
242
+ // Bulk
243
+ updateSelectedStyle: (style) =>
244
+ set((state) => {
245
+ if (!state.model || state.selectedSegmentIds.size === 0) return {};
246
+ const segments = new Map(state.model.segments);
247
+ for (const segmentId of state.selectedSegmentIds) {
248
+ const segment = segments.get(segmentId);
249
+ if (segment) {
250
+ segments.set(segmentId, { ...segment, style: { ...segment.style, ...style } });
251
+ }
252
+ }
253
+ return { model: { ...state.model, segments } };
254
+ }),
255
+
256
+ applyAnimationToAll: (animation) =>
257
+ set((state) => {
258
+ if (!state.model) return {};
259
+ const groups = new Map(state.model.groups);
260
+ for (const [id, group] of groups) {
261
+ groups.set(id, { ...group, animation });
262
+ }
263
+ return { model: { ...state.model, groups } };
264
+ }),
265
+
266
+ // Reset
267
+ reset: () =>
268
+ set({
269
+ ...initialState,
270
+ selectedSegmentIds: new Set<string>(),
271
+ }),
272
+ }));