@bendyline/squisq 0.1.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.
Files changed (102) hide show
  1. package/dist/Types-sh2VRxfo.d.ts +15 -0
  2. package/dist/chunk-7UDSRZKG.js +103 -0
  3. package/dist/chunk-7UDSRZKG.js.map +1 -0
  4. package/dist/chunk-O5HCIUAD.js +577 -0
  5. package/dist/chunk-O5HCIUAD.js.map +1 -0
  6. package/dist/chunk-PJ7AODIM.js +3040 -0
  7. package/dist/chunk-PJ7AODIM.js.map +1 -0
  8. package/dist/chunk-QWVRN6I4.js +81 -0
  9. package/dist/chunk-QWVRN6I4.js.map +1 -0
  10. package/dist/chunk-S4F2RY37.js +92 -0
  11. package/dist/chunk-S4F2RY37.js.map +1 -0
  12. package/dist/chunk-URU6QMLY.js +888 -0
  13. package/dist/chunk-URU6QMLY.js.map +1 -0
  14. package/dist/chunk-VJN7UB2Z.js +145 -0
  15. package/dist/chunk-VJN7UB2Z.js.map +1 -0
  16. package/dist/doc/index.d.ts +3 -0
  17. package/dist/doc/index.js +126 -0
  18. package/dist/doc/index.js.map +1 -0
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.js +217 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/markdown/index.d.ts +237 -0
  23. package/dist/markdown/index.js +33 -0
  24. package/dist/markdown/index.js.map +1 -0
  25. package/dist/schemas/index.d.ts +70 -0
  26. package/dist/schemas/index.js +61 -0
  27. package/dist/schemas/index.js.map +1 -0
  28. package/dist/spatial/index.d.ts +111 -0
  29. package/dist/spatial/index.js +25 -0
  30. package/dist/spatial/index.js.map +1 -0
  31. package/dist/storage/index.d.ts +91 -0
  32. package/dist/storage/index.js +11 -0
  33. package/dist/storage/index.js.map +1 -0
  34. package/dist/story/index.d.ts +691 -0
  35. package/dist/story/index.js +126 -0
  36. package/dist/story/index.js.map +1 -0
  37. package/dist/themeLibrary-DySHPcgj.d.ts +1352 -0
  38. package/dist/types-DHiv_Pnm.d.ts +496 -0
  39. package/package.json +91 -0
  40. package/src/__tests__/animationUtils.test.ts +77 -0
  41. package/src/__tests__/geohash.test.ts +109 -0
  42. package/src/__tests__/getLayers.test.ts +200 -0
  43. package/src/__tests__/haversine.test.ts +63 -0
  44. package/src/__tests__/localForageAdapter.test.ts +155 -0
  45. package/src/__tests__/markdown.test.ts +806 -0
  46. package/src/__tests__/markdownToDoc.test.ts +374 -0
  47. package/src/__tests__/storage.test.ts +60 -0
  48. package/src/__tests__/templates.test.ts +247 -0
  49. package/src/doc/docToMarkdown.ts +98 -0
  50. package/src/doc/getLayers.ts +152 -0
  51. package/src/doc/index.ts +8 -0
  52. package/src/doc/markdownToDoc.ts +277 -0
  53. package/src/doc/templates/accentImage.ts +338 -0
  54. package/src/doc/templates/captionUtils.ts +33 -0
  55. package/src/doc/templates/comparisonBar.ts +140 -0
  56. package/src/doc/templates/coverBlock.ts +156 -0
  57. package/src/doc/templates/dateEvent.ts +122 -0
  58. package/src/doc/templates/definitionCard.ts +129 -0
  59. package/src/doc/templates/factCard.ts +114 -0
  60. package/src/doc/templates/fullBleedQuote.ts +59 -0
  61. package/src/doc/templates/imageWithCaption.ts +167 -0
  62. package/src/doc/templates/index.ts +571 -0
  63. package/src/doc/templates/listBlock.ts +101 -0
  64. package/src/doc/templates/mapBlock.ts +132 -0
  65. package/src/doc/templates/persistentLayers.ts +522 -0
  66. package/src/doc/templates/photoGrid.ts +199 -0
  67. package/src/doc/templates/pullQuote.ts +119 -0
  68. package/src/doc/templates/quoteBlock.ts +112 -0
  69. package/src/doc/templates/sectionHeader.ts +105 -0
  70. package/src/doc/templates/statHighlight.ts +113 -0
  71. package/src/doc/templates/titleBlock.ts +100 -0
  72. package/src/doc/templates/twoColumn.ts +239 -0
  73. package/src/doc/templates/videoPullQuote.ts +128 -0
  74. package/src/doc/templates/videoWithCaption.ts +126 -0
  75. package/src/doc/utils/animationUtils.ts +135 -0
  76. package/src/doc/utils/themeUtils.ts +139 -0
  77. package/src/index.ts +5 -0
  78. package/src/markdown/convert.ts +897 -0
  79. package/src/markdown/htmlParse.ts +212 -0
  80. package/src/markdown/index.ts +110 -0
  81. package/src/markdown/parse.ts +103 -0
  82. package/src/markdown/stringify.ts +122 -0
  83. package/src/markdown/types.ts +605 -0
  84. package/src/markdown/utils.ts +165 -0
  85. package/src/ngeohash.d.ts +11 -0
  86. package/src/schemas/BlockTemplates.ts +706 -0
  87. package/src/schemas/Doc.ts +565 -0
  88. package/src/schemas/LayoutStrategy.ts +178 -0
  89. package/src/schemas/MediaProvider.ts +70 -0
  90. package/src/schemas/Theme.ts +235 -0
  91. package/src/schemas/Types.ts +15 -0
  92. package/src/schemas/Viewport.ts +91 -0
  93. package/src/schemas/index.ts +14 -0
  94. package/src/schemas/themeLibrary.ts +492 -0
  95. package/src/spatial/Geohash.ts +168 -0
  96. package/src/spatial/Haversine.ts +59 -0
  97. package/src/spatial/index.ts +2 -0
  98. package/src/storage/LocalForageAdapter.ts +103 -0
  99. package/src/storage/LocalStorageAdapter.ts +57 -0
  100. package/src/storage/MemoryStorageAdapter.ts +37 -0
  101. package/src/storage/Storage.ts +28 -0
  102. package/src/storage/index.ts +5 -0
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Title Block Template
3
+ *
4
+ * Large title with optional subtitle for doc intros.
5
+ * Centered text with fade-in animations.
6
+ * Adapts font sizes and positioning for different viewports.
7
+ *
8
+ * This is shared code used by both site and efb-app doc renderers.
9
+ */
10
+
11
+ import type { Layer } from '../../schemas/Doc.js';
12
+ import type { TitleBlockInput, TemplateContext } from '../../schemas/BlockTemplates.js';
13
+ import { scaledFontSize } from '../../schemas/BlockTemplates.js';
14
+
15
+ export function titleBlock(input: TitleBlockInput, context: TemplateContext): Layer[] {
16
+ const { title, subtitle, backgroundColor } = input;
17
+ const { theme, layout } = context;
18
+
19
+ // Scale font sizes for viewport
20
+ const titleFontSize = scaledFontSize(96, context, true);
21
+ const subtitleFontSize = scaledFontSize(36, context, false);
22
+
23
+ const baseBg = backgroundColor || theme.colors.primary;
24
+
25
+ const layers: Layer[] = [
26
+ // Background — radial gradient for depth instead of flat color
27
+ {
28
+ type: 'shape',
29
+ id: 'bg',
30
+ content: {
31
+ shape: 'rect',
32
+ fill: `radial-gradient(ellipse at 50% 40%, ${baseBg} 0%, ${theme.colors.background} 100%)`,
33
+ },
34
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
35
+ },
36
+ // Subtle decorative line above title
37
+ {
38
+ type: 'shape',
39
+ id: 'accent-line',
40
+ content: {
41
+ shape: 'rect',
42
+ fill: 'rgba(255, 255, 255, 0.2)',
43
+ },
44
+ position: {
45
+ x: '40%',
46
+ y: subtitle ? '28%' : '38%',
47
+ width: '20%',
48
+ height: '2px',
49
+ },
50
+ },
51
+ // Title
52
+ {
53
+ type: 'text',
54
+ id: 'title',
55
+ content: {
56
+ text: title,
57
+ style: {
58
+ fontSize: titleFontSize,
59
+ fontWeight: 'bold',
60
+ color: theme.colors.text,
61
+ textAlign: 'center',
62
+ shadow: true,
63
+ },
64
+ },
65
+ position: {
66
+ x: '50%',
67
+ y: subtitle ? layout.primaryY : '50%',
68
+ anchor: 'center',
69
+ width: layout.maxTextWidth,
70
+ },
71
+ animation: { type: 'fadeIn', duration: 2 },
72
+ },
73
+ ];
74
+
75
+ // Add subtitle if provided
76
+ if (subtitle) {
77
+ layers.push({
78
+ type: 'text',
79
+ id: 'subtitle',
80
+ content: {
81
+ text: subtitle,
82
+ style: {
83
+ fontSize: subtitleFontSize,
84
+ color: theme.colors.textMuted,
85
+ textAlign: 'center',
86
+ lineHeight: 1.5,
87
+ },
88
+ },
89
+ position: {
90
+ x: '50%',
91
+ y: layout.secondaryY,
92
+ anchor: 'center',
93
+ width: layout.maxTextWidth,
94
+ },
95
+ animation: { type: 'fadeIn', duration: 1.5, delay: 1 },
96
+ });
97
+ }
98
+
99
+ return layers;
100
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Two Column Template
3
+ *
4
+ * Side-by-side comparison of two items.
5
+ * Each column has a label and optional sublabel.
6
+ * Adapts font sizes and positioning for different viewports.
7
+ *
8
+ * Portrait mode: Stacks columns vertically (top/bottom) instead of side-by-side.
9
+ *
10
+ * This is shared code used by both site and efb-app doc renderers.
11
+ */
12
+
13
+ import type { Layer } from '../../schemas/Doc.js';
14
+ import type { TwoColumnInput, TemplateContext } from '../../schemas/BlockTemplates.js';
15
+ import { scaledFontSize } from '../../schemas/BlockTemplates.js';
16
+ import { resolveColorScheme } from '../utils/themeUtils.js';
17
+ import { getTwoColumnPositions } from '../../schemas/LayoutStrategy.js';
18
+
19
+ export function twoColumn(input: TwoColumnInput, context: TemplateContext): Layer[] {
20
+ const { left, right, header, leftColor = 'green', rightColor = 'blue' } = input;
21
+
22
+ // Guard: required fields may be absent when generated from sparse markdown
23
+ if (!left?.label || !right?.label) return [];
24
+
25
+ const { theme, layout, orientation } = context;
26
+ const leftColors = resolveColorScheme(context, leftColor);
27
+ const rightColors = resolveColorScheme(context, rightColor);
28
+
29
+ // Get column positions based on orientation
30
+ const positions = getTwoColumnPositions(orientation);
31
+ const isStacked = layout.stackColumns;
32
+
33
+ // Column width ~42% for side-by-side, ~85% for stacked
34
+ const columnWidth = isStacked ? '85%' : '42%';
35
+
36
+ // Calculate font sizes based on viewport
37
+ const headerFontSize = scaledFontSize(32, context, false);
38
+ const labelFontSize = scaledFontSize(44, context, true);
39
+ const sublabelFontSize = scaledFontSize(22, context, false);
40
+
41
+ const layers: Layer[] = [
42
+ // Background — subtle gradient to add depth
43
+ {
44
+ type: 'shape',
45
+ id: 'bg',
46
+ content: {
47
+ shape: 'rect',
48
+ fill: `linear-gradient(135deg, ${theme.colors.background} 0%, #16202e 100%)`,
49
+ },
50
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
51
+ },
52
+ ];
53
+
54
+ if (!isStacked) {
55
+ // Left column background panel
56
+ layers.push({
57
+ type: 'shape',
58
+ id: 'left-panel',
59
+ content: {
60
+ shape: 'rect',
61
+ fill: 'rgba(255, 255, 255, 0.04)',
62
+ },
63
+ position: { x: '3%', y: '15%', width: '44%', height: '70%' },
64
+ });
65
+
66
+ // Right column background panel
67
+ layers.push({
68
+ type: 'shape',
69
+ id: 'right-panel',
70
+ content: {
71
+ shape: 'rect',
72
+ fill: 'rgba(255, 255, 255, 0.04)',
73
+ },
74
+ position: { x: '53%', y: '15%', width: '44%', height: '70%' },
75
+ });
76
+ }
77
+
78
+ // Header position adjusts based on layout
79
+ const headerY = isStacked ? '12%' : '22%';
80
+
81
+ // Add header if provided
82
+ if (header) {
83
+ layers.push({
84
+ type: 'text',
85
+ id: 'header',
86
+ content: {
87
+ text: header,
88
+ style: {
89
+ fontSize: headerFontSize,
90
+ color: theme.colors.textMuted,
91
+ textAlign: 'center',
92
+ },
93
+ },
94
+ position: { x: '50%', y: headerY, anchor: 'center' },
95
+ animation: { type: 'fadeIn', duration: 0.8 },
96
+ });
97
+ }
98
+
99
+ // Calculate Y positions based on layout mode and header presence
100
+ let leftY: string;
101
+ let leftSublabelY: string;
102
+ let rightY: string;
103
+ let rightSublabelY: string;
104
+
105
+ if (isStacked) {
106
+ // Portrait: stacked vertically
107
+ leftY = header ? '30%' : '25%';
108
+ leftSublabelY = header ? '40%' : '35%';
109
+ rightY = header ? '60%' : '55%';
110
+ rightSublabelY = header ? '70%' : '65%';
111
+ } else {
112
+ // Landscape/square: side by side — vertically centered in panels (15%-85%)
113
+ const baseY = header ? '47%' : '45%';
114
+ const sublabelOffset = header ? '58%' : '56%';
115
+ leftY = baseY;
116
+ leftSublabelY = sublabelOffset;
117
+ rightY = baseY;
118
+ rightSublabelY = sublabelOffset;
119
+ }
120
+
121
+ // Left column (or top in portrait)
122
+ layers.push({
123
+ type: 'text',
124
+ id: 'left-label',
125
+ content: {
126
+ text: left.label,
127
+ style: {
128
+ fontSize: labelFontSize,
129
+ fontWeight: 'bold',
130
+ color: leftColors.text,
131
+ textAlign: 'center',
132
+ lineHeight: 1.3,
133
+ },
134
+ },
135
+ position: {
136
+ x: positions.left.x,
137
+ y: leftY,
138
+ anchor: 'center',
139
+ width: columnWidth,
140
+ },
141
+ animation: { type: 'fadeIn', duration: 1 },
142
+ });
143
+
144
+ if (left.sublabel) {
145
+ layers.push({
146
+ type: 'text',
147
+ id: 'left-sublabel',
148
+ content: {
149
+ text: left.sublabel,
150
+ style: {
151
+ fontSize: sublabelFontSize,
152
+ color: theme.colors.textMuted,
153
+ textAlign: 'center',
154
+ lineHeight: 1.4,
155
+ },
156
+ },
157
+ position: {
158
+ x: positions.left.x,
159
+ y: leftSublabelY,
160
+ anchor: 'center',
161
+ width: columnWidth,
162
+ },
163
+ animation: { type: 'fadeIn', duration: 0.8, delay: 0.3 },
164
+ });
165
+ }
166
+
167
+ // Visual connector between columns (bold arrow)
168
+ const connectorFontSize = scaledFontSize(120, context, true);
169
+ const connectorY = isStacked ? '48%' : '50%'; // Centered in viewport
170
+ const connectorSymbol = isStacked ? '\u2193' : '\u2192'; // ↓ for stacked, → for side-by-side
171
+
172
+ layers.push({
173
+ type: 'text',
174
+ id: 'connector',
175
+ content: {
176
+ text: connectorSymbol,
177
+ style: {
178
+ fontSize: connectorFontSize,
179
+ fontWeight: 'bold',
180
+ color: 'rgba(255, 255, 255, 0.6)',
181
+ textAlign: 'center',
182
+ },
183
+ },
184
+ position: {
185
+ x: '50%',
186
+ y: connectorY,
187
+ anchor: 'center',
188
+ },
189
+ animation: { type: 'fadeIn', duration: 0.6, delay: 0.4 },
190
+ });
191
+
192
+ // Right column (or bottom in portrait)
193
+ layers.push({
194
+ type: 'text',
195
+ id: 'right-label',
196
+ content: {
197
+ text: right.label,
198
+ style: {
199
+ fontSize: labelFontSize,
200
+ fontWeight: 'bold',
201
+ color: rightColors.text,
202
+ textAlign: 'center',
203
+ lineHeight: 1.3,
204
+ },
205
+ },
206
+ position: {
207
+ x: positions.right.x,
208
+ y: rightY,
209
+ anchor: 'center',
210
+ width: columnWidth,
211
+ },
212
+ animation: { type: 'fadeIn', duration: 1, delay: 0.5 },
213
+ });
214
+
215
+ if (right.sublabel) {
216
+ layers.push({
217
+ type: 'text',
218
+ id: 'right-sublabel',
219
+ content: {
220
+ text: right.sublabel,
221
+ style: {
222
+ fontSize: sublabelFontSize,
223
+ color: theme.colors.textMuted,
224
+ textAlign: 'center',
225
+ lineHeight: 1.4,
226
+ },
227
+ },
228
+ position: {
229
+ x: positions.right.x,
230
+ y: rightSublabelY,
231
+ anchor: 'center',
232
+ width: columnWidth,
233
+ },
234
+ animation: { type: 'fadeIn', duration: 0.8, delay: 0.8 },
235
+ });
236
+ }
237
+
238
+ return layers;
239
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Video Pull Quote Template
3
+ *
4
+ * Quote text over a video clip background with dark overlay.
5
+ * Cinematic alternative to pullQuote when a video clip is available.
6
+ * Combines the visual dynamism of live video with the text focus of quoteBlock.
7
+ *
8
+ * The video plays muted — narration audio is the only sound track.
9
+ *
10
+ * This is shared code used by both site and efb-app doc renderers.
11
+ *
12
+ * Related Files:
13
+ * - shared/story/templates/pullQuote.ts — image-based equivalent
14
+ * - schemas/StoryScript.ts — VideoLayer type
15
+ * - site/src/components/story/layers/VideoLayer.tsx — rendering component
16
+ */
17
+
18
+ import type { Layer } from '../../schemas/Doc.js';
19
+ import type { VideoPullQuoteInput, TemplateContext } from '../../schemas/BlockTemplates.js';
20
+ import { scaledFontSize } from '../../schemas/BlockTemplates.js';
21
+
22
+ export function videoPullQuote(input: VideoPullQuoteInput, context: TemplateContext): Layer[] {
23
+ const { text, attribution, backgroundVideo } = input;
24
+
25
+ // Guard: backgroundVideo is required
26
+ if (!backgroundVideo?.src) return [];
27
+
28
+ const quoteFontSize = scaledFontSize(52, context, true);
29
+ const attrFontSize = scaledFontSize(24, context, false);
30
+ const decoFontSize = scaledFontSize(200, context, true);
31
+
32
+ const layers: Layer[] = [
33
+ // Full-bleed background video clip
34
+ {
35
+ type: 'video',
36
+ id: 'bg-video',
37
+ content: {
38
+ src: backgroundVideo.src,
39
+ posterSrc: backgroundVideo.posterSrc,
40
+ alt: backgroundVideo.alt,
41
+ fit: 'cover',
42
+ clipStart: backgroundVideo.clipStart,
43
+ clipEnd: backgroundVideo.clipEnd,
44
+ credit: backgroundVideo.credit,
45
+ license: backgroundVideo.license,
46
+ },
47
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
48
+ },
49
+
50
+ // Dark overlay for text readability
51
+ {
52
+ type: 'shape',
53
+ id: 'overlay',
54
+ content: {
55
+ shape: 'rect',
56
+ fill: 'rgba(0, 0, 0, 0.55)',
57
+ },
58
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
59
+ },
60
+
61
+ // Decorative quotation mark
62
+ {
63
+ type: 'text',
64
+ id: 'deco-quote',
65
+ content: {
66
+ text: '\u201C',
67
+ style: {
68
+ fontSize: decoFontSize,
69
+ color: 'rgba(255, 255, 255, 0.08)',
70
+ textAlign: 'center',
71
+ },
72
+ },
73
+ position: {
74
+ x: '50%',
75
+ y: '20%',
76
+ anchor: 'center',
77
+ },
78
+ },
79
+
80
+ // Quote text
81
+ {
82
+ type: 'text',
83
+ id: 'quote-text',
84
+ content: {
85
+ text,
86
+ style: {
87
+ fontSize: quoteFontSize,
88
+ color: '#ffffff',
89
+ textAlign: 'center',
90
+ lineHeight: 1.5,
91
+ shadow: true,
92
+ },
93
+ },
94
+ position: {
95
+ x: '50%',
96
+ y: attribution ? '45%' : '50%',
97
+ anchor: 'center',
98
+ width: '80%',
99
+ },
100
+ animation: { type: 'fadeIn', duration: 2 },
101
+ },
102
+ ];
103
+
104
+ // Attribution
105
+ if (attribution) {
106
+ layers.push({
107
+ type: 'text',
108
+ id: 'attribution',
109
+ content: {
110
+ text: `\u2014 ${attribution}`,
111
+ style: {
112
+ fontSize: attrFontSize,
113
+ color: 'rgba(255, 255, 255, 0.7)',
114
+ textAlign: 'center',
115
+ shadow: true,
116
+ },
117
+ },
118
+ position: {
119
+ x: '50%',
120
+ y: '72%',
121
+ anchor: 'center',
122
+ },
123
+ animation: { type: 'fadeIn', duration: 1, delay: 1.5 },
124
+ });
125
+ }
126
+
127
+ return layers;
128
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Video With Caption Template
3
+ *
4
+ * Full-screen background video clip with text overlay. Mirrors the structure of
5
+ * imageWithCaption but uses a VideoLayer instead of an ImageLayer. The video
6
+ * plays muted — narration audio is the only sound track.
7
+ *
8
+ * Adapts caption positioning and font sizes for different viewports.
9
+ *
10
+ * This is shared code used by both site and efb-app doc renderers.
11
+ *
12
+ * Related Files:
13
+ * - shared/story/templates/imageWithCaption.ts — image equivalent
14
+ * - schemas/StoryScript.ts — VideoLayer type
15
+ * - site/src/components/story/layers/VideoLayer.tsx — rendering component
16
+ */
17
+
18
+ import type { Layer } from '../../schemas/Doc.js';
19
+ import type { VideoWithCaptionInput, TemplateContext } from '../../schemas/BlockTemplates.js';
20
+ import { scaledFontSize } from '../../schemas/BlockTemplates.js';
21
+ import { cleanCaption } from './captionUtils.js';
22
+
23
+ export function videoWithCaption(input: VideoWithCaptionInput, context: TemplateContext): Layer[] {
24
+ const {
25
+ videoSrc,
26
+ posterSrc,
27
+ videoAlt,
28
+ clipStart,
29
+ clipEnd,
30
+ sourceDuration,
31
+ caption: rawCaption,
32
+ captionPosition = 'bottom',
33
+ videoCredit,
34
+ videoLicense,
35
+ } = input;
36
+ const caption = rawCaption ? cleanCaption(rawCaption) : rawCaption;
37
+ const { theme, layout } = context;
38
+
39
+ const captionFontSize = scaledFontSize(36, context, false);
40
+ const creditFontSize = scaledFontSize(16, context, false);
41
+
42
+ const layers: Layer[] = [
43
+ // Background video clip
44
+ {
45
+ type: 'video',
46
+ id: 'bg-video',
47
+ content: {
48
+ src: videoSrc,
49
+ posterSrc,
50
+ alt: videoAlt,
51
+ fit: 'cover',
52
+ clipStart,
53
+ clipEnd,
54
+ sourceDuration,
55
+ credit: videoCredit,
56
+ license: videoLicense,
57
+ },
58
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
59
+ },
60
+ ];
61
+
62
+ // Caption text at bottom with gradient for readability
63
+ if (caption) {
64
+ layers.push({
65
+ type: 'shape',
66
+ id: 'caption-gradient',
67
+ content: {
68
+ shape: 'rect',
69
+ fill: 'linear-gradient(0deg, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.3) 60%, transparent 100%)',
70
+ },
71
+ position: {
72
+ x: 0,
73
+ y: captionPosition === 'top' ? 0 : '65%',
74
+ width: '100%',
75
+ height: '35%',
76
+ },
77
+ });
78
+
79
+ const captionY =
80
+ captionPosition === 'top' ? '15%' : captionPosition === 'center' ? '50%' : '82%';
81
+
82
+ layers.push({
83
+ type: 'text',
84
+ id: 'caption',
85
+ content: {
86
+ text: caption,
87
+ style: {
88
+ fontSize: captionFontSize,
89
+ color: theme.colors.text,
90
+ textAlign: 'center',
91
+ shadow: true,
92
+ },
93
+ },
94
+ position: {
95
+ x: '50%',
96
+ y: captionY,
97
+ anchor: 'center',
98
+ width: layout.maxTextWidth,
99
+ },
100
+ animation: { type: 'fadeIn', duration: 1.5, delay: 0.5 },
101
+ });
102
+ }
103
+
104
+ // Small credit text in bottom-right corner
105
+ if (videoCredit) {
106
+ layers.push({
107
+ type: 'text',
108
+ id: 'credit',
109
+ content: {
110
+ text: videoCredit,
111
+ style: {
112
+ fontSize: creditFontSize,
113
+ color: 'rgba(255, 255, 255, 0.5)',
114
+ textAlign: 'right',
115
+ },
116
+ },
117
+ position: {
118
+ x: '96%',
119
+ y: '96%',
120
+ anchor: 'bottom-right',
121
+ },
122
+ });
123
+ }
124
+
125
+ return layers;
126
+ }