@bendyline/squisq-react 1.0.2 → 1.0.3

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.
Files changed (73) hide show
  1. package/dist/CaptionOverlay.d.ts +16 -7
  2. package/dist/CaptionOverlay.d.ts.map +1 -1
  3. package/dist/CaptionOverlay.js +7 -3
  4. package/dist/CaptionOverlay.js.map +1 -1
  5. package/dist/DocControlsBottom.d.ts.map +1 -1
  6. package/dist/DocControlsBottom.js +5 -1
  7. package/dist/DocControlsBottom.js.map +1 -1
  8. package/dist/DocControlsOverlay.d.ts.map +1 -1
  9. package/dist/DocControlsOverlay.js +9 -4
  10. package/dist/DocControlsOverlay.js.map +1 -1
  11. package/dist/DocControlsSidebar.d.ts.map +1 -1
  12. package/dist/DocControlsSidebar.js +5 -1
  13. package/dist/DocControlsSidebar.js.map +1 -1
  14. package/dist/DocPlayer.d.ts +5 -2
  15. package/dist/DocPlayer.d.ts.map +1 -1
  16. package/dist/DocPlayer.js +36 -7
  17. package/dist/DocPlayer.js.map +1 -1
  18. package/dist/DocPlayerWithSidebar.d.ts.map +1 -1
  19. package/dist/DocPlayerWithSidebar.js +1 -0
  20. package/dist/DocPlayerWithSidebar.js.map +1 -1
  21. package/dist/DocProgressBar.js +2 -2
  22. package/dist/LinearDocView.d.ts.map +1 -1
  23. package/dist/LinearDocView.js +88 -6
  24. package/dist/LinearDocView.js.map +1 -1
  25. package/dist/SocialCaptionOverlay.d.ts +27 -0
  26. package/dist/SocialCaptionOverlay.d.ts.map +1 -0
  27. package/dist/SocialCaptionOverlay.js +184 -0
  28. package/dist/SocialCaptionOverlay.js.map +1 -0
  29. package/dist/__tests__/DocControlsSlideshow.test.js +1 -0
  30. package/dist/__tests__/DocControlsSlideshow.test.js.map +1 -1
  31. package/dist/hooks/useAudioSync.d.ts.map +1 -1
  32. package/dist/hooks/useAudioSync.js +4 -2
  33. package/dist/hooks/useAudioSync.js.map +1 -1
  34. package/dist/hooks/useDocPlayback.d.ts.map +1 -1
  35. package/dist/hooks/useDocPlayback.js +7 -4
  36. package/dist/hooks/useDocPlayback.js.map +1 -1
  37. package/dist/index.d.ts +2 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/layers/ShapeLayer.d.ts.map +1 -1
  42. package/dist/layers/ShapeLayer.js +7 -3
  43. package/dist/layers/ShapeLayer.js.map +1 -1
  44. package/dist/layers/TextLayer.js +1 -1
  45. package/dist/layers/TextLayer.js.map +1 -1
  46. package/dist/squisq-player.global.js +70 -5
  47. package/dist/squisq-player.global.js.map +1 -1
  48. package/dist/standalone-entry.d.ts +8 -0
  49. package/dist/standalone-entry.d.ts.map +1 -1
  50. package/dist/standalone-entry.js +6 -3
  51. package/dist/standalone-entry.js.map +1 -1
  52. package/dist/standalone-source.js +1 -1
  53. package/dist/types.d.ts +19 -0
  54. package/dist/types.d.ts.map +1 -1
  55. package/dist/types.js.map +1 -1
  56. package/package.json +2 -2
  57. package/src/CaptionOverlay.tsx +33 -8
  58. package/src/DocControlsBottom.tsx +11 -5
  59. package/src/DocControlsOverlay.tsx +18 -6
  60. package/src/DocControlsSidebar.tsx +11 -5
  61. package/src/DocPlayer.tsx +56 -9
  62. package/src/DocPlayerWithSidebar.tsx +1 -0
  63. package/src/DocProgressBar.tsx +2 -2
  64. package/src/LinearDocView.tsx +103 -11
  65. package/src/SocialCaptionOverlay.tsx +254 -0
  66. package/src/__tests__/DocControlsSlideshow.test.tsx +1 -0
  67. package/src/hooks/useAudioSync.ts +5 -2
  68. package/src/hooks/useDocPlayback.ts +8 -3
  69. package/src/index.ts +3 -0
  70. package/src/layers/ShapeLayer.tsx +8 -3
  71. package/src/layers/TextLayer.tsx +1 -1
  72. package/src/standalone-entry.tsx +23 -3
  73. package/src/types.ts +21 -0
@@ -0,0 +1,254 @@
1
+ /**
2
+ * SocialCaptionOverlay Component
3
+ *
4
+ * Social media-style captions (Instagram/TikTok): large centered words
5
+ * showing 3-5 words at a time with the currently-spoken word highlighted
6
+ * in the theme's primary color. Font and colors are pulled from the
7
+ * active theme.
8
+ *
9
+ * Words are gathered across all caption phrases into a continuous stream,
10
+ * then chunked into uniform groups for smooth, consistent pacing.
11
+ *
12
+ * Supports two timing modes:
13
+ * 1. Precise: uses per-word timestamps from CaptionPhrase.words
14
+ * 2. Interpolated: distributes timing evenly within each phrase
15
+ */
16
+
17
+ import { useMemo } from 'react';
18
+ import type { CaptionTrack, CaptionPhrase, ViewportConfig } from '@bendyline/squisq/schemas';
19
+ import type { Theme } from '@bendyline/squisq/schemas';
20
+
21
+ /** Target words per visible chunk. */
22
+ const TARGET_CHUNK_SIZE = 4;
23
+ const MIN_CHUNK_SIZE = 2;
24
+ const MAX_CHUNK_SIZE = 6;
25
+
26
+ /** A timed word derived from phrase data. */
27
+ interface TimedWord {
28
+ text: string;
29
+ startTime: number;
30
+ endTime: number;
31
+ }
32
+
33
+ /** A chunk of words displayed together. */
34
+ interface WordChunk {
35
+ words: TimedWord[];
36
+ startTime: number;
37
+ endTime: number;
38
+ }
39
+
40
+ /**
41
+ * Resolve per-word timing for a single phrase.
42
+ * Uses precise word timestamps when available, otherwise interpolates.
43
+ */
44
+ function resolvePhraseTiming(phrase: CaptionPhrase): TimedWord[] {
45
+ // Use precise timing when available
46
+ if (phrase.words && phrase.words.length > 0) {
47
+ return phrase.words.map((w) => ({
48
+ text: w.text,
49
+ startTime: w.startTime,
50
+ endTime: w.endTime,
51
+ }));
52
+ }
53
+
54
+ // Interpolate: split text into words, distribute evenly
55
+ const rawWords = phrase.text.split(/\s+/).filter((w) => w.length > 0);
56
+ if (rawWords.length === 0) return [];
57
+
58
+ const duration = phrase.endTime - phrase.startTime;
59
+ const wordDuration = duration / rawWords.length;
60
+
61
+ return rawWords.map((text, i) => ({
62
+ text,
63
+ startTime: phrase.startTime + i * wordDuration,
64
+ endTime: phrase.startTime + (i + 1) * wordDuration,
65
+ }));
66
+ }
67
+
68
+ /**
69
+ * Build a continuous stream of timed words from ALL caption phrases,
70
+ * then group into uniform display chunks. This eliminates the "pulsy"
71
+ * feeling caused by per-phrase chunking with variable phrase lengths.
72
+ */
73
+ function buildWordStream(captions: CaptionTrack): { words: TimedWord[]; chunks: WordChunk[] } {
74
+ // Gather all words across all phrases
75
+ const allWords: TimedWord[] = [];
76
+ for (const phrase of captions.phrases) {
77
+ allWords.push(...resolvePhraseTiming(phrase));
78
+ }
79
+
80
+ if (allWords.length === 0) return { words: [], chunks: [] };
81
+
82
+ // Determine uniform chunk size
83
+ let chunkSize = TARGET_CHUNK_SIZE;
84
+ if (allWords.length > chunkSize) {
85
+ const numChunks = Math.ceil(allWords.length / chunkSize);
86
+ chunkSize = Math.ceil(allWords.length / numChunks);
87
+ chunkSize = Math.min(MAX_CHUNK_SIZE, Math.max(MIN_CHUNK_SIZE, chunkSize));
88
+ } else {
89
+ chunkSize = Math.max(MIN_CHUNK_SIZE, allWords.length);
90
+ }
91
+
92
+ // Build chunks
93
+ const chunks: WordChunk[] = [];
94
+ for (let i = 0; i < allWords.length; i += chunkSize) {
95
+ const chunkWords = allWords.slice(i, i + chunkSize);
96
+ chunks.push({
97
+ words: chunkWords,
98
+ startTime: chunkWords[0].startTime,
99
+ endTime: chunkWords[chunkWords.length - 1].endTime,
100
+ });
101
+ }
102
+
103
+ return { words: allWords, chunks };
104
+ }
105
+
106
+ interface SocialCaptionOverlayProps {
107
+ captions: CaptionTrack | undefined;
108
+ currentTime: number;
109
+ enabled?: boolean;
110
+ theme?: Theme;
111
+ viewport?: ViewportConfig;
112
+ }
113
+
114
+ export function SocialCaptionOverlay({
115
+ captions,
116
+ currentTime,
117
+ enabled = true,
118
+ theme,
119
+ viewport,
120
+ }: SocialCaptionOverlayProps) {
121
+ // Build the word stream once when captions change (memoized)
122
+ const { chunks } = useMemo(
123
+ () => (captions ? buildWordStream(captions) : { words: [], chunks: [] }),
124
+ [captions],
125
+ );
126
+
127
+ if (!enabled || chunks.length === 0) {
128
+ return (
129
+ <div
130
+ className="social-caption-overlay"
131
+ style={{
132
+ position: 'absolute',
133
+ bottom: '18%',
134
+ left: 0,
135
+ right: 0,
136
+ zIndex: 50,
137
+ pointerEvents: 'none',
138
+ opacity: 0,
139
+ transition: 'opacity 0.15s ease-in-out',
140
+ }}
141
+ />
142
+ );
143
+ }
144
+
145
+ // Find the active chunk and word using binary-style search
146
+ let activeChunk: WordChunk | null = null;
147
+ let activeWordIndex = -1;
148
+
149
+ for (const chunk of chunks) {
150
+ if (currentTime >= chunk.startTime && currentTime < chunk.endTime) {
151
+ activeChunk = chunk;
152
+ break;
153
+ }
154
+ }
155
+
156
+ // If between chunks (gap), show the nearest chunk
157
+ if (!activeChunk) {
158
+ for (let i = 0; i < chunks.length - 1; i++) {
159
+ if (currentTime >= chunks[i].endTime && currentTime < chunks[i + 1].startTime) {
160
+ // In a gap — show the chunk we just left (feels more natural)
161
+ activeChunk = chunks[i];
162
+ activeWordIndex = activeChunk.words.length - 1;
163
+ break;
164
+ }
165
+ }
166
+ // Past all chunks
167
+ if (!activeChunk && chunks.length > 0 && currentTime >= chunks[chunks.length - 1].startTime) {
168
+ activeChunk = chunks[chunks.length - 1];
169
+ activeWordIndex = activeChunk.words.length - 1;
170
+ }
171
+ }
172
+
173
+ if (!activeChunk) return null;
174
+
175
+ // Find active word within chunk (if not already set from gap handling)
176
+ if (activeWordIndex === -1) {
177
+ for (let i = 0; i < activeChunk.words.length; i++) {
178
+ const word = activeChunk.words[i];
179
+ if (currentTime >= word.startTime && currentTime < word.endTime) {
180
+ activeWordIndex = i;
181
+ break;
182
+ }
183
+ }
184
+ // Fallback: last word before currentTime
185
+ if (activeWordIndex === -1) {
186
+ for (let i = activeChunk.words.length - 1; i >= 0; i--) {
187
+ if (currentTime >= activeChunk.words[i].startTime) {
188
+ activeWordIndex = i;
189
+ break;
190
+ }
191
+ }
192
+ }
193
+ // Final fallback
194
+ if (activeWordIndex === -1) activeWordIndex = 0;
195
+ }
196
+
197
+ // Theme-derived styling
198
+ const primaryColor = theme?.colors?.primary ?? '#5b9bd5';
199
+ const fontFamily = theme?.typography?.titleFontFamily
200
+ ? `"${theme.typography.titleFontFamily}", system-ui, sans-serif`
201
+ : '"PT Serif", Georgia, serif';
202
+
203
+ // Scale font to viewport — aim for ~5.5% of viewport height
204
+ const viewportHeight = viewport?.height ?? 720;
205
+ const baseFontSize = Math.round(viewportHeight * 0.055);
206
+ const fontSize = Math.max(24, Math.min(72, baseFontSize));
207
+
208
+ return (
209
+ <div
210
+ className="social-caption-overlay"
211
+ style={{
212
+ position: 'absolute',
213
+ bottom: '18%',
214
+ left: 0,
215
+ right: 0,
216
+ zIndex: 50,
217
+ pointerEvents: 'none',
218
+ textAlign: 'center',
219
+ padding: '0 8%',
220
+ boxSizing: 'border-box',
221
+ opacity: 1,
222
+ transition: 'opacity 0.15s ease-in-out',
223
+ }}
224
+ >
225
+ <div
226
+ style={{
227
+ display: 'inline-block',
228
+ lineHeight: 1.3,
229
+ }}
230
+ >
231
+ {activeChunk.words.map((word, i) => {
232
+ const isActive = i === activeWordIndex;
233
+ return (
234
+ <span
235
+ key={`${word.startTime}-${i}`}
236
+ style={{
237
+ fontFamily,
238
+ fontSize: `${fontSize}px`,
239
+ fontWeight: isActive ? 800 : 600,
240
+ color: isActive ? primaryColor : 'rgba(255, 255, 255, 0.9)',
241
+ textShadow: '0 2px 8px rgba(0,0,0,0.7), 0 0 20px rgba(0,0,0,0.4)',
242
+ marginRight: i < activeChunk!.words.length - 1 ? '0.3em' : undefined,
243
+ transition: 'color 0.1s ease, font-weight 0.1s ease',
244
+ textTransform: 'uppercase',
245
+ }}
246
+ >
247
+ {word.text}
248
+ </span>
249
+ );
250
+ })}
251
+ </div>
252
+ </div>
253
+ );
254
+ }
@@ -13,6 +13,7 @@ function makeState(overrides: Partial<PlaybackState> = {}): PlaybackState {
13
13
  docProgress: 0.2,
14
14
  hasCaptions: false,
15
15
  captionsEnabled: false,
16
+ captionMode: 'off',
16
17
  currentSegmentIndex: 0,
17
18
  currentSegmentName: null,
18
19
  currentBlock: null,
@@ -277,8 +277,11 @@ export function useAudioSync(
277
277
  const audio = audioRef.current;
278
278
  if (!audio || !audioTrack?.segments) return;
279
279
 
280
- // Clamp time to valid range
281
- const clampedTime = Math.max(0, Math.min(time, totalDuration));
280
+ // Clamp time to valid range.
281
+ // When totalDuration is 0 (no audio segments), don't clamp — allow
282
+ // seeking by block timing alone (used in render mode / preview).
283
+ const clampedTime =
284
+ totalDuration > 0 ? Math.max(0, Math.min(time, totalDuration)) : Math.max(0, time);
282
285
 
283
286
  // Find which segment this time falls into
284
287
  let segmentIndex = 0;
@@ -18,6 +18,7 @@ import type { Theme } from '@bendyline/squisq/schemas';
18
18
  import { getBlockAtTime } from '@bendyline/squisq/schemas';
19
19
  import {
20
20
  expandDocBlocks,
21
+ flattenBlocks,
21
22
  isTemplateBlock,
22
23
  VIEWPORT_PRESETS,
23
24
  type ViewportConfig,
@@ -76,8 +77,12 @@ export function useDocPlayback(
76
77
  return [];
77
78
  }
78
79
 
80
+ // Flatten nested block hierarchy (markdown-derived docs have children)
81
+ const hasChildren = script.blocks.some((b) => b.children && b.children.length > 0);
82
+ const flatBlocks = hasChildren ? flattenBlocks(script.blocks) : script.blocks;
83
+
79
84
  // Check if any blocks are templates
80
- const hasTemplates = script.blocks.some(isTemplateBlock);
85
+ const hasTemplates = flatBlocks.some(isTemplateBlock);
81
86
 
82
87
  if (hasTemplates) {
83
88
  // Extract audio segment timing for proper block synchronization
@@ -87,7 +92,7 @@ export function useDocPlayback(
87
92
  }));
88
93
 
89
94
  // Expand template blocks with audio segment timing, viewport, and persistent layers
90
- const expanded = expandDocBlocks(script.blocks as DocBlock[], {
95
+ const expanded = expandDocBlocks(flatBlocks as DocBlock[], {
91
96
  audioSegments,
92
97
  viewport,
93
98
  persistentLayers: script.persistentLayers,
@@ -97,7 +102,7 @@ export function useDocPlayback(
97
102
  }
98
103
 
99
104
  // All raw blocks, use as-is
100
- return script.blocks;
105
+ return flatBlocks;
101
106
  }, [script?.blocks, script?.audio?.segments, script?.persistentLayers, viewport, theme]);
102
107
 
103
108
  // Find current block based on time
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  export { DocPlayer } from './DocPlayer.js';
3
3
  export { BlockRenderer, VIEWPORT } from './BlockRenderer.js';
4
4
  export { CaptionOverlay } from './CaptionOverlay.js';
5
+ export { SocialCaptionOverlay } from './SocialCaptionOverlay.js';
5
6
  export { DocControlsOverlay } from './DocControlsOverlay.js';
6
7
  export { DocControlsBottom } from './DocControlsBottom.js';
7
8
  export { DocControlsSidebar } from './DocControlsSidebar.js';
@@ -32,6 +33,8 @@ export type {
32
33
  BlockMarker,
33
34
  ControlsLayout,
34
35
  DisplayMode,
36
+ CaptionStyle,
37
+ CaptionMode,
35
38
  SlideNavActions,
36
39
  SquisqRenderAPI,
37
40
  SquisqWindow,
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { ShapeLayer as ShapeLayerType } from '@bendyline/squisq/schemas';
9
9
  import { getAnimationStyle } from '../utils/animationUtils';
10
- import { resolveValue } from '../utils/layerUtils';
10
+ import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
11
11
 
12
12
  interface ShapeLayerProps {
13
13
  layer: ShapeLayerType;
@@ -21,11 +21,16 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
21
21
  const { content, position, animation } = layer;
22
22
 
23
23
  // Resolve position values to pixels
24
- const x = resolveValue(position.x, viewport.width);
25
- const y = resolveValue(position.y, viewport.height);
24
+ const rawX = resolveValue(position.x, viewport.width);
25
+ const rawY = resolveValue(position.y, viewport.height);
26
26
  const width = position.width ? resolveValue(position.width, viewport.width) : 100;
27
27
  const height = position.height ? resolveValue(position.height, viewport.height) : 100;
28
28
 
29
+ // Apply anchor offset (e.g., 'center' shifts x by -width/2 and y by -height/2)
30
+ const anchorOffset = getAnchorOffset(position.anchor, width, height);
31
+ const x = rawX + anchorOffset.x;
32
+ const y = rawY + anchorOffset.y;
33
+
29
34
  // Get animation styles
30
35
  const animStyle = getAnimationStyle(animation, blockTime);
31
36
 
@@ -38,7 +38,7 @@ export function TextLayer({ layer, viewport, blockTime }: TextLayerProps) {
38
38
  const animStyle = getAnimationStyle(animation, blockTime);
39
39
 
40
40
  // Split text into lines, and wrap if maxWidth is specified
41
- const rawLines = text.split('\n');
41
+ const rawLines = (text ?? '').split('\n');
42
42
  let lines = maxWidth
43
43
  ? rawLines.reduce<string[]>(
44
44
  (acc, line) => acc.concat(wrapText(line, style.fontSize, maxWidth)),
@@ -53,6 +53,14 @@ export interface MountOptions {
53
53
  theme?: Theme;
54
54
  /** Auto-play on mount (only for slideshow mode, default: false) */
55
55
  autoPlay?: boolean;
56
+ /**
57
+ * Enable render mode for headless frame capture.
58
+ * Exposes window.seekTo(), getDuration(), getCaptions(), etc.
59
+ * Disables controls and auto-play. Used by Playwright video export.
60
+ */
61
+ renderMode?: boolean;
62
+ /** Caption style: 'standard' or 'social'. Omit or set to undefined for no captions. */
63
+ captionStyle?: 'standard' | 'social';
56
64
  }
57
65
 
58
66
  // ── CSS Injection ──────────────────────────────────────────────────
@@ -159,7 +167,16 @@ const roots = new WeakMap<Element, Root>();
159
167
  export function mount(element: Element, doc: Doc, options: MountOptions = {}): void {
160
168
  injectCss();
161
169
 
162
- const { mode = 'slideshow', basePath = '.', images, audio, autoPlay = false, theme } = options;
170
+ const {
171
+ mode = 'slideshow',
172
+ basePath = '.',
173
+ images,
174
+ audio,
175
+ autoPlay = false,
176
+ theme,
177
+ renderMode = false,
178
+ captionStyle,
179
+ } = options;
163
180
 
164
181
  // Rewrite audio URLs if map provided
165
182
  const finalDoc = audio ? rewriteAudioUrls(doc, audio) : doc;
@@ -180,9 +197,12 @@ export function mount(element: Element, doc: Doc, options: MountOptions = {}): v
180
197
  script: finalDoc,
181
198
  basePath,
182
199
  displayMode: 'slideshow',
183
- autoPlay,
184
- showControls: true,
200
+ autoPlay: renderMode ? false : autoPlay,
201
+ showControls: !renderMode,
202
+ renderMode,
185
203
  theme,
204
+ captionsEnabled: !!captionStyle,
205
+ captionStyle: captionStyle ?? 'standard',
186
206
  });
187
207
  }
188
208
 
package/src/types.ts CHANGED
@@ -32,6 +32,23 @@ export type ControlsLayout = 'overlay' | 'sidebar' | 'bottom';
32
32
  */
33
33
  export type DisplayMode = 'video' | 'slideshow' | 'linear';
34
34
 
35
+ /**
36
+ * Caption display style.
37
+ *
38
+ * - `'standard'` — Traditional broadcast-style captions: small white text
39
+ * on a semi-transparent black badge at the top of the player.
40
+ * - `'social'` — Social media-style (Instagram/TikTok): large centered words
41
+ * showing 3-5 words at a time with the active word highlighted in the
42
+ * theme's primary color. Font and colors pulled from the active theme.
43
+ */
44
+ export type CaptionStyle = 'standard' | 'social';
45
+
46
+ /**
47
+ * Caption display mode — combines enable/disable with style selection.
48
+ * The CC button cycles through: off → standard → social → off.
49
+ */
50
+ export type CaptionMode = 'off' | 'standard' | 'social';
51
+
35
52
  /** Slide navigation actions for slideshow display mode */
36
53
  export interface SlideNavActions {
37
54
  /** Navigate to the next slide */
@@ -52,6 +69,8 @@ export interface PlaybackState {
52
69
  docProgress: number;
53
70
  hasCaptions: boolean;
54
71
  captionsEnabled: boolean;
72
+ /** Current caption display mode (off, standard, social). */
73
+ captionMode: CaptionMode;
55
74
  isFullscreen?: boolean;
56
75
  /** Current audio segment index (0-based) */
57
76
  currentSegmentIndex: number;
@@ -67,6 +86,8 @@ export interface PlaybackActions {
67
86
  restart: () => void;
68
87
  seekTo: (time: number) => void;
69
88
  setCaptionsEnabled: (enabled: boolean) => void;
89
+ /** Cycle caption mode: off → standard → social → off */
90
+ cycleCaptionMode: () => void;
70
91
  toggleFullscreen?: () => void;
71
92
  }
72
93