@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,168 @@
1
+ import { useCallback, useRef } from "react";
2
+ import { useCaptionStore } from "../store";
3
+ import { useMountEffect } from "../../hooks/useMountEffect";
4
+ import type { CaptionStyle } from "../types";
5
+
6
+ interface CaptionOverrideEntry {
7
+ wordId?: string;
8
+ wordIndex: number;
9
+ x?: number;
10
+ y?: number;
11
+ scale?: number;
12
+ rotation?: number;
13
+ activeColor?: string;
14
+ dimColor?: string;
15
+ opacity?: number;
16
+ fontSize?: number;
17
+ fontWeight?: number;
18
+ fontFamily?: string;
19
+ }
20
+
21
+ function buildOverrides(model: {
22
+ groupOrder: string[];
23
+ groups: Map<string, { segmentIds: string[] }>;
24
+ segments: Map<string, { wordId?: string; style: Partial<CaptionStyle> }>;
25
+ }): CaptionOverrideEntry[] {
26
+ const entries: CaptionOverrideEntry[] = [];
27
+ let globalWordIndex = 0;
28
+
29
+ for (const groupId of model.groupOrder) {
30
+ const group = model.groups.get(groupId);
31
+ if (!group) continue;
32
+ for (const segId of group.segmentIds) {
33
+ const seg = model.segments.get(segId);
34
+ if (seg && Object.keys(seg.style).length > 0) {
35
+ const entry: CaptionOverrideEntry = { wordIndex: globalWordIndex };
36
+ if (seg.wordId) entry.wordId = seg.wordId;
37
+ const s = seg.style;
38
+ if (s.x !== undefined) entry.x = s.x;
39
+ if (s.y !== undefined) entry.y = s.y;
40
+ if (s.scaleX !== undefined) entry.scale = s.scaleX;
41
+ if (s.rotation !== undefined) entry.rotation = s.rotation;
42
+ if (s.activeColor !== undefined) entry.activeColor = s.activeColor;
43
+ if (s.dimColor !== undefined) entry.dimColor = s.dimColor;
44
+ if (s.opacity !== undefined) entry.opacity = s.opacity;
45
+ if (s.fontSize !== undefined) entry.fontSize = s.fontSize;
46
+ if (s.fontWeight !== undefined) entry.fontWeight = s.fontWeight as number;
47
+ if (s.fontFamily !== undefined) entry.fontFamily = s.fontFamily;
48
+ entries.push(entry);
49
+ }
50
+ globalWordIndex++;
51
+ }
52
+ }
53
+
54
+ return entries;
55
+ }
56
+
57
+ /**
58
+ * Auto-saves caption overrides to caption-overrides.json on every model change.
59
+ * Also provides loadOverrides for reading existing overrides on edit mode entry.
60
+ */
61
+ export function useCaptionSync(projectId: string | null) {
62
+ const projectIdRef = useRef(projectId);
63
+ projectIdRef.current = projectId;
64
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
65
+
66
+ // Flag to suppress auto-save during loadOverrides
67
+ const suppressSaveRef = useRef(false);
68
+
69
+ const save = useCallback(() => {
70
+ const state = useCaptionStore.getState();
71
+ if (!state.model || !state.sourceFilePath || !state.isEditMode) return;
72
+ const pid = projectIdRef.current;
73
+ if (!pid) return;
74
+
75
+ const overrides = buildOverrides(state.model);
76
+
77
+ fetch(`/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`, {
78
+ method: "PUT",
79
+ headers: { "Content-Type": "text/plain" },
80
+ body: JSON.stringify(overrides, null, 2),
81
+ }).catch((err) => console.warn("[captions] auto-save failed:", err));
82
+ }, []);
83
+
84
+ // Auto-save on model changes with 800ms debounce
85
+ useMountEffect(() => {
86
+ let prevModel = useCaptionStore.getState().model;
87
+
88
+ const unsub = useCaptionStore.subscribe((state) => {
89
+ if (!state.isEditMode || state.model === prevModel || !state.model) return;
90
+ prevModel = state.model;
91
+
92
+ // Skip save when loadOverrides just updated the model
93
+ if (suppressSaveRef.current) {
94
+ suppressSaveRef.current = false;
95
+ return;
96
+ }
97
+
98
+ if (debounceRef.current) clearTimeout(debounceRef.current);
99
+ debounceRef.current = setTimeout(save, 800);
100
+ });
101
+
102
+ return () => {
103
+ unsub();
104
+ if (debounceRef.current) clearTimeout(debounceRef.current);
105
+ };
106
+ });
107
+
108
+ const loadOverrides = useCallback(async () => {
109
+ const state = useCaptionStore.getState();
110
+ if (!state.model || !state.sourceFilePath) return;
111
+ const pid = projectIdRef.current;
112
+ if (!pid) return;
113
+
114
+ try {
115
+ const res = await fetch(
116
+ `/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`,
117
+ );
118
+ if (!res.ok) return;
119
+ const data = await res.json();
120
+ if (!data.content) return;
121
+
122
+ const overrides: CaptionOverrideEntry[] = JSON.parse(data.content);
123
+ if (!Array.isArray(overrides)) return;
124
+
125
+ const model = state.model;
126
+ const allSegIds: string[] = [];
127
+ for (const groupId of model.groupOrder) {
128
+ const group = model.groups.get(groupId);
129
+ if (!group) continue;
130
+ for (const segId of group.segmentIds) {
131
+ allSegIds.push(segId);
132
+ }
133
+ }
134
+
135
+ const newSegments = new Map(model.segments);
136
+ for (const override of overrides) {
137
+ const segId = allSegIds[override.wordIndex];
138
+ if (!segId) continue;
139
+ const seg = newSegments.get(segId);
140
+ if (!seg) continue;
141
+
142
+ const style: Partial<CaptionStyle> = { ...seg.style };
143
+ if (override.x !== undefined) style.x = override.x;
144
+ if (override.y !== undefined) style.y = override.y;
145
+ if (override.scale !== undefined) {
146
+ style.scaleX = override.scale;
147
+ style.scaleY = override.scale;
148
+ }
149
+ if (override.rotation !== undefined) style.rotation = override.rotation;
150
+ if (override.activeColor !== undefined) style.activeColor = override.activeColor;
151
+ if (override.dimColor !== undefined) style.dimColor = override.dimColor;
152
+ if (override.opacity !== undefined) style.opacity = override.opacity;
153
+ if (override.fontSize !== undefined) style.fontSize = override.fontSize;
154
+ if (override.fontWeight !== undefined) style.fontWeight = override.fontWeight;
155
+ if (override.fontFamily !== undefined) style.fontFamily = override.fontFamily;
156
+
157
+ newSegments.set(segId, { ...seg, style });
158
+ }
159
+
160
+ suppressSaveRef.current = true;
161
+ useCaptionStore.getState().setModel({ ...model, segments: newSegments });
162
+ } catch {
163
+ // No overrides file
164
+ }
165
+ }, []);
166
+
167
+ return { save, loadOverrides };
168
+ }
@@ -0,0 +1,10 @@
1
+ export { useCaptionStore } from "./store";
2
+ export { parseCaptionComposition, extractTranscript, buildCaptionModel } from "./parser";
3
+ export type { TranscriptWord } from "./parser";
4
+ export { generateCaptionHtml } from "./generator";
5
+ export { CaptionOverlay } from "./components/CaptionOverlay";
6
+ export { CaptionPropertyPanel } from "./components/CaptionPropertyPanel";
7
+ export { CaptionAnimationPanel } from "./components/CaptionAnimationPanel";
8
+ export { CaptionTimeline } from "./components/CaptionTimeline";
9
+ export { useCaptionSync } from "./hooks/useCaptionSync";
10
+ export type * from "./types";
@@ -0,0 +1,377 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect } from "vitest";
3
+ import { extractTranscript, buildCaptionModel, TranscriptWord } from "./parser.js";
4
+ import { DEFAULT_STYLE, DEFAULT_CONTAINER, DEFAULT_ANIMATION_SET } from "./types.js";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Fixtures
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const STANDARD_CAPTION_SOURCE = `
11
+ (function () {
12
+ const TRANSCRIPT = [
13
+ { text: "We", start: 0.119, end: 0.259 },
14
+ { text: "asked", start: 0.319, end: 0.479 },
15
+ { text: "what", start: 0.519, end: 0.659 },
16
+ { text: "you", start: 0.699, end: 0.819 },
17
+ { text: "needed.", start: 0.859, end: 1.819 },
18
+ ];
19
+ // rest of composition code ...
20
+ })();
21
+ `;
22
+
23
+ const SCRIPT_VARIABLE_SOURCE = `
24
+ (function () {
25
+ const script = [
26
+ { text: "We", start: 0.119, end: 0.259 },
27
+ { text: "asked", start: 0.319, end: 0.479 },
28
+ { text: "what", start: 0.519, end: 0.659 },
29
+ ];
30
+ // rest of composition code ...
31
+ })();
32
+ `;
33
+
34
+ const LET_TRANSCRIPT_SOURCE = `
35
+ (function () {
36
+ let TRANSCRIPT = [
37
+ { text: "Hello", start: 0.0, end: 0.5 },
38
+ { text: "world", start: 0.6, end: 1.0 },
39
+ ];
40
+ })();
41
+ `;
42
+
43
+ const VAR_TRANSCRIPT_SOURCE = `
44
+ (function () {
45
+ var TRANSCRIPT = [
46
+ { text: "Hello", start: 0.0, end: 0.5 },
47
+ ];
48
+ })();
49
+ `;
50
+
51
+ const SINGLE_QUOTED_SOURCE = `
52
+ (function () {
53
+ const TRANSCRIPT = [
54
+ { text: 'We', start: 0.119, end: 0.259 },
55
+ { text: 'asked', start: 0.319, end: 0.479 },
56
+ ];
57
+ })();
58
+ `;
59
+
60
+ const TRAILING_COMMA_SOURCE = `
61
+ (function () {
62
+ const TRANSCRIPT = [
63
+ { text: "We", start: 0.119, end: 0.259, },
64
+ { text: "asked", start: 0.319, end: 0.479, },
65
+ ];
66
+ })();
67
+ `;
68
+
69
+ const NON_CAPTION_SOURCE = `
70
+ (function () {
71
+ const config = { fps: 30, duration: 10 };
72
+ const elements = ["title", "subtitle"];
73
+ gsap.to(".clip", { opacity: 1 });
74
+ })();
75
+ `;
76
+
77
+ const EMPTY_SOURCE = ``;
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Tests
81
+ // ---------------------------------------------------------------------------
82
+
83
+ describe("extractTranscript", () => {
84
+ describe("TRANSCRIPT variable", () => {
85
+ it("extracts words from a standard TRANSCRIPT array", () => {
86
+ const words = extractTranscript(STANDARD_CAPTION_SOURCE);
87
+ expect(words).toHaveLength(5);
88
+ expect(words[0]).toEqual({ text: "We", start: 0.119, end: 0.259 });
89
+ expect(words[1]).toEqual({ text: "asked", start: 0.319, end: 0.479 });
90
+ expect(words[4]).toEqual({ text: "needed.", start: 0.859, end: 1.819 });
91
+ });
92
+
93
+ it("handles let TRANSCRIPT declaration", () => {
94
+ const words = extractTranscript(LET_TRANSCRIPT_SOURCE);
95
+ expect(words).toHaveLength(2);
96
+ expect(words[0]).toEqual({ text: "Hello", start: 0.0, end: 0.5 });
97
+ expect(words[1]).toEqual({ text: "world", start: 0.6, end: 1.0 });
98
+ });
99
+
100
+ it("handles var TRANSCRIPT declaration", () => {
101
+ const words = extractTranscript(VAR_TRANSCRIPT_SOURCE);
102
+ expect(words).toHaveLength(1);
103
+ expect(words[0]).toEqual({ text: "Hello", start: 0.0, end: 0.5 });
104
+ });
105
+ });
106
+
107
+ describe("script variable name", () => {
108
+ it("extracts words from a const script array (warm-grain template variant)", () => {
109
+ const words = extractTranscript(SCRIPT_VARIABLE_SOURCE);
110
+ expect(words).toHaveLength(3);
111
+ expect(words[0]).toEqual({ text: "We", start: 0.119, end: 0.259 });
112
+ expect(words[2]).toEqual({ text: "what", start: 0.519, end: 0.659 });
113
+ });
114
+ });
115
+
116
+ describe("non-caption source", () => {
117
+ it("returns empty array when no TRANSCRIPT or script variable is found", () => {
118
+ const words = extractTranscript(NON_CAPTION_SOURCE);
119
+ expect(words).toEqual([]);
120
+ });
121
+
122
+ it("returns empty array for an empty string", () => {
123
+ const words = extractTranscript(EMPTY_SOURCE);
124
+ expect(words).toEqual([]);
125
+ });
126
+ });
127
+
128
+ describe("single-quoted values", () => {
129
+ it("parses arrays with single-quoted text values", () => {
130
+ const words = extractTranscript(SINGLE_QUOTED_SOURCE);
131
+ expect(words).toHaveLength(2);
132
+ expect(words[0]).toEqual({ text: "We", start: 0.119, end: 0.259 });
133
+ expect(words[1]).toEqual({ text: "asked", start: 0.319, end: 0.479 });
134
+ });
135
+ });
136
+
137
+ describe("trailing commas", () => {
138
+ it("handles trailing commas inside objects", () => {
139
+ const words = extractTranscript(TRAILING_COMMA_SOURCE);
140
+ expect(words).toHaveLength(2);
141
+ expect(words[0]).toEqual({ text: "We", start: 0.119, end: 0.259 });
142
+ });
143
+ });
144
+
145
+ describe("real-world source samples", () => {
146
+ it("handles a realistic production-style TRANSCRIPT block with many words", () => {
147
+ const source = `
148
+ (function() {
149
+ const TRANSCRIPT = [
150
+ { text: "We", start: 0.119, end: 0.259 },
151
+ { text: "asked", start: 0.319, end: 0.479 },
152
+ { text: "what", start: 0.519, end: 0.659 },
153
+ { text: "you", start: 0.699, end: 0.819 },
154
+ { text: "needed.", start: 0.859, end: 1.819 },
155
+ { text: "Forty-seven", start: 1.86, end: 2.299 },
156
+ { text: "percent", start: 2.399, end: 2.679 },
157
+ { text: "of", start: 2.7, end: 2.799 },
158
+ ];
159
+ })();
160
+ `;
161
+ const words = extractTranscript(source);
162
+ expect(words).toHaveLength(8);
163
+ expect(words[5]).toEqual({ text: "Forty-seven", start: 1.86, end: 2.299 });
164
+ });
165
+
166
+ it("handles words with punctuation in text values", () => {
167
+ const source = `
168
+ const TRANSCRIPT = [
169
+ { text: "graphics,", start: 3.579, end: 4.599 },
170
+ { text: "you", start: 4.679, end: 5.179 },
171
+ { text: "attention.", start: 5.299, end: 5.759 },
172
+ ];
173
+ `;
174
+ const words = extractTranscript(source);
175
+ expect(words).toHaveLength(3);
176
+ expect(words[0].text).toBe("graphics,");
177
+ expect(words[2].text).toBe("attention.");
178
+ });
179
+ });
180
+ });
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // buildCaptionModel tests
184
+ // ---------------------------------------------------------------------------
185
+
186
+ const SEVEN_WORDS: TranscriptWord[] = [
187
+ { text: "We", start: 0.1, end: 0.3 },
188
+ { text: "asked", start: 0.4, end: 0.6 },
189
+ { text: "what", start: 0.7, end: 0.9 },
190
+ { text: "you", start: 1.0, end: 1.2 },
191
+ { text: "needed.", start: 1.3, end: 1.8 },
192
+ { text: "Forty-seven", start: 1.9, end: 2.3 },
193
+ { text: "percent", start: 2.4, end: 2.7 },
194
+ ];
195
+
196
+ describe("buildCaptionModel", () => {
197
+ describe("grouping", () => {
198
+ it("produces 2 groups for 7 words with wordsPerGroup=5 (5 + 2)", () => {
199
+ const model = buildCaptionModel(SEVEN_WORDS, {
200
+ width: 1920,
201
+ height: 1080,
202
+ duration: 10,
203
+ wordsPerGroup: 5,
204
+ });
205
+ expect(model.groupOrder).toHaveLength(2);
206
+ expect(model.groups.size).toBe(2);
207
+ });
208
+
209
+ it("first group has 5 segments and second group has 2 segments", () => {
210
+ const model = buildCaptionModel(SEVEN_WORDS, {
211
+ width: 1920,
212
+ height: 1080,
213
+ duration: 10,
214
+ wordsPerGroup: 5,
215
+ });
216
+ const firstGroupId = model.groupOrder[0];
217
+ const secondGroupId = model.groupOrder[1];
218
+ expect(model.groups.get(firstGroupId)?.segmentIds).toHaveLength(5);
219
+ expect(model.groups.get(secondGroupId)?.segmentIds).toHaveLength(2);
220
+ });
221
+
222
+ it("uses default wordsPerGroup of 5 when not specified", () => {
223
+ const model = buildCaptionModel(SEVEN_WORDS, {
224
+ width: 1280,
225
+ height: 720,
226
+ duration: 5,
227
+ });
228
+ expect(model.groupOrder).toHaveLength(2);
229
+ });
230
+ });
231
+
232
+ describe("segments", () => {
233
+ it("segments have correct text matching the transcript words", () => {
234
+ const model = buildCaptionModel(SEVEN_WORDS, {
235
+ width: 1920,
236
+ height: 1080,
237
+ duration: 10,
238
+ wordsPerGroup: 5,
239
+ });
240
+ expect(model.segments.size).toBe(7);
241
+
242
+ const firstGroupId = model.groupOrder[0];
243
+ const firstGroup = model.groups.get(firstGroupId);
244
+ const firstSegmentId = firstGroup?.segmentIds[0];
245
+ const firstSegment = firstSegmentId ? model.segments.get(firstSegmentId) : undefined;
246
+ expect(firstSegment?.text).toBe("We");
247
+
248
+ const secondGroupId = model.groupOrder[1];
249
+ const secondGroup = model.groups.get(secondGroupId);
250
+ const sixthSegmentId = secondGroup?.segmentIds[0];
251
+ const sixthSegment = sixthSegmentId ? model.segments.get(sixthSegmentId) : undefined;
252
+ expect(sixthSegment?.text).toBe("Forty-seven");
253
+ });
254
+
255
+ it("segments have correct start and end timing from the transcript", () => {
256
+ const model = buildCaptionModel(SEVEN_WORDS, {
257
+ width: 1920,
258
+ height: 1080,
259
+ duration: 10,
260
+ wordsPerGroup: 5,
261
+ });
262
+ const firstGroupId = model.groupOrder[0];
263
+ const firstGroup = model.groups.get(firstGroupId);
264
+ const segId = firstGroup?.segmentIds[4];
265
+ const fifthSegment = segId ? model.segments.get(segId) : undefined;
266
+ expect(fifthSegment?.start).toBe(1.3);
267
+ expect(fifthSegment?.end).toBe(1.8);
268
+ expect(fifthSegment?.text).toBe("needed.");
269
+ });
270
+
271
+ it("segments have correct groupIndex reflecting position within their group", () => {
272
+ const model = buildCaptionModel(SEVEN_WORDS, {
273
+ width: 1920,
274
+ height: 1080,
275
+ duration: 10,
276
+ wordsPerGroup: 5,
277
+ });
278
+ const secondGroupId = model.groupOrder[1];
279
+ const secondGroup = model.groups.get(secondGroupId);
280
+ const segId = secondGroup?.segmentIds[1];
281
+ const segment = segId ? model.segments.get(segId) : undefined;
282
+ expect(segment?.groupIndex).toBe(1);
283
+ });
284
+ });
285
+
286
+ describe("model dimensions", () => {
287
+ it("stores correct width, height, and duration on the model", () => {
288
+ const model = buildCaptionModel(SEVEN_WORDS, {
289
+ width: 1920,
290
+ height: 1080,
291
+ duration: 30.5,
292
+ wordsPerGroup: 5,
293
+ });
294
+ expect(model.width).toBe(1920);
295
+ expect(model.height).toBe(1080);
296
+ expect(model.duration).toBe(30.5);
297
+ });
298
+ });
299
+
300
+ describe("default styles", () => {
301
+ it("groups have DEFAULT_STYLE applied", () => {
302
+ const model = buildCaptionModel(SEVEN_WORDS, {
303
+ width: 1920,
304
+ height: 1080,
305
+ duration: 10,
306
+ wordsPerGroup: 5,
307
+ });
308
+ const firstGroupId = model.groupOrder[0];
309
+ const group = model.groups.get(firstGroupId);
310
+ expect(group?.style).toEqual(DEFAULT_STYLE);
311
+ });
312
+
313
+ it("groups have DEFAULT_CONTAINER applied", () => {
314
+ const model = buildCaptionModel(SEVEN_WORDS, {
315
+ width: 1920,
316
+ height: 1080,
317
+ duration: 10,
318
+ wordsPerGroup: 5,
319
+ });
320
+ const firstGroupId = model.groupOrder[0];
321
+ const group = model.groups.get(firstGroupId);
322
+ expect(group?.containerStyle).toEqual(DEFAULT_CONTAINER);
323
+ });
324
+
325
+ it("groups have DEFAULT_ANIMATION_SET applied", () => {
326
+ const model = buildCaptionModel(SEVEN_WORDS, {
327
+ width: 1920,
328
+ height: 1080,
329
+ duration: 10,
330
+ wordsPerGroup: 5,
331
+ });
332
+ const firstGroupId = model.groupOrder[0];
333
+ const group = model.groups.get(firstGroupId);
334
+ expect(group?.animation.entrance).toEqual(DEFAULT_ANIMATION_SET.entrance);
335
+ expect(group?.animation.highlight).toBe(DEFAULT_ANIMATION_SET.highlight);
336
+ expect(group?.animation.exit).toEqual(DEFAULT_ANIMATION_SET.exit);
337
+ });
338
+
339
+ it("model defaultAnimation matches DEFAULT_ANIMATION_SET", () => {
340
+ const model = buildCaptionModel(SEVEN_WORDS, {
341
+ width: 1920,
342
+ height: 1080,
343
+ duration: 10,
344
+ wordsPerGroup: 5,
345
+ });
346
+ expect(model.defaultAnimation.entrance).toEqual(DEFAULT_ANIMATION_SET.entrance);
347
+ expect(model.defaultAnimation.highlight).toBe(DEFAULT_ANIMATION_SET.highlight);
348
+ expect(model.defaultAnimation.exit).toEqual(DEFAULT_ANIMATION_SET.exit);
349
+ });
350
+ });
351
+
352
+ describe("edge cases", () => {
353
+ it("handles an empty transcript returning a model with no segments or groups", () => {
354
+ const model = buildCaptionModel([], {
355
+ width: 1920,
356
+ height: 1080,
357
+ duration: 10,
358
+ wordsPerGroup: 5,
359
+ });
360
+ expect(model.segments.size).toBe(0);
361
+ expect(model.groups.size).toBe(0);
362
+ expect(model.groupOrder).toHaveLength(0);
363
+ });
364
+
365
+ it("handles transcript with exactly wordsPerGroup words producing 1 group", () => {
366
+ const fiveWords = SEVEN_WORDS.slice(0, 5);
367
+ const model = buildCaptionModel(fiveWords, {
368
+ width: 1920,
369
+ height: 1080,
370
+ duration: 10,
371
+ wordsPerGroup: 5,
372
+ });
373
+ expect(model.groupOrder).toHaveLength(1);
374
+ expect(model.segments.size).toBe(5);
375
+ });
376
+ });
377
+ });