@bendyline/squisq 1.4.1 → 1.5.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 (254) hide show
  1. package/README.md +14 -1
  2. package/dist/{ContentContainer-DNx460y8.d.ts → ContentContainer-BXUlIf18.d.ts} +6 -2
  3. package/dist/{Doc-BeSeH2iX.d.ts → Doc-BBVGq1_9.d.ts} +922 -273
  4. package/dist/{ImageEditDoc-BTIvw0oq.d.ts → ImageEditDoc-FuyGtt6o.d.ts} +11 -4
  5. package/dist/{chunk-ZTX4PFFT.js → chunk-2OIBZYKP.js} +75 -2
  6. package/dist/chunk-2OIBZYKP.js.map +1 -0
  7. package/dist/chunk-2UQOHYE2.js +45 -0
  8. package/dist/chunk-2UQOHYE2.js.map +1 -0
  9. package/dist/chunk-6ATE2PSM.js +344 -0
  10. package/dist/chunk-6ATE2PSM.js.map +1 -0
  11. package/dist/{chunk-57CVQCFX.js → chunk-A4O7GIWE.js} +74 -23
  12. package/dist/chunk-A4O7GIWE.js.map +1 -0
  13. package/dist/{chunk-5WFRKURX.js → chunk-DLXZMS5K.js} +4 -2
  14. package/dist/chunk-DLXZMS5K.js.map +1 -0
  15. package/dist/chunk-ET53IIEP.js +1 -0
  16. package/dist/chunk-ET53IIEP.js.map +1 -0
  17. package/dist/chunk-HWVFJAAH.js +362 -0
  18. package/dist/chunk-HWVFJAAH.js.map +1 -0
  19. package/dist/{chunk-2TMAWB4Y.js → chunk-IMSCRLLV.js} +1 -1
  20. package/dist/chunk-IMSCRLLV.js.map +1 -0
  21. package/dist/{chunk-B4EEON3N.js → chunk-KKNUBQ6Y.js} +536 -103
  22. package/dist/chunk-KKNUBQ6Y.js.map +1 -0
  23. package/dist/chunk-LDQ2HJIX.js +596 -0
  24. package/dist/chunk-LDQ2HJIX.js.map +1 -0
  25. package/dist/chunk-LH7I6SH7.js +7208 -0
  26. package/dist/chunk-LH7I6SH7.js.map +1 -0
  27. package/dist/chunk-PGGGKYWG.js +1 -0
  28. package/dist/chunk-PGGGKYWG.js.map +1 -0
  29. package/dist/{chunk-7UDSRZKG.js → chunk-Q3ROPT5H.js} +35 -14
  30. package/dist/chunk-Q3ROPT5H.js.map +1 -0
  31. package/dist/chunk-QE7TWGVT.js +178 -0
  32. package/dist/chunk-QE7TWGVT.js.map +1 -0
  33. package/dist/chunk-RUDYOTA6.js +345 -0
  34. package/dist/chunk-RUDYOTA6.js.map +1 -0
  35. package/dist/{chunk-K32VJONL.js → chunk-T5UK6YOB.js} +41 -2
  36. package/dist/chunk-T5UK6YOB.js.map +1 -0
  37. package/dist/{chunk-3K5OG6KI.js → chunk-TEEEILMP.js} +412 -186
  38. package/dist/chunk-TEEEILMP.js.map +1 -0
  39. package/dist/{chunk-FR2RBTKO.js → chunk-TQWLI6S2.js} +93 -27
  40. package/dist/chunk-TQWLI6S2.js.map +1 -0
  41. package/dist/{chunk-WMOI7FN3.js → chunk-UY7KGQ5R.js} +12 -2
  42. package/dist/{chunk-WMOI7FN3.js.map → chunk-UY7KGQ5R.js.map} +1 -1
  43. package/dist/{chunk-3E5F2XMR.js → chunk-VWUFZ6ZG.js} +29 -2
  44. package/dist/chunk-VWUFZ6ZG.js.map +1 -0
  45. package/dist/chunk-ZLQKNOZQ.js +1 -0
  46. package/dist/chunk-ZLQKNOZQ.js.map +1 -0
  47. package/dist/doc/index.d.ts +5 -5
  48. package/dist/doc/index.js +135 -8
  49. package/dist/generate/index.d.ts +3 -74
  50. package/dist/generate/index.js +2 -7
  51. package/dist/icons/index.d.ts +3 -26
  52. package/dist/icons/index.js +13 -0
  53. package/dist/icons/inlineIconMarker.d.ts +1 -0
  54. package/dist/icons/inlineIconMarker.js +15 -0
  55. package/dist/icons/inlineIconMarker.js.map +1 -0
  56. package/dist/imageEdit/index.d.ts +12 -6
  57. package/dist/imageEdit/index.js +4 -1
  58. package/dist/index.d.ts +14 -11
  59. package/dist/index.js +265 -37
  60. package/dist/inlineIconMarker-CiE8Hlvz.d.ts +59 -0
  61. package/dist/jsonForm/index.d.ts +42 -1
  62. package/dist/jsonForm/index.js +9 -1
  63. package/dist/markdown/index.d.ts +228 -3
  64. package/dist/markdown/index.js +42 -7
  65. package/dist/recommend/index.d.ts +1 -1
  66. package/dist/recommend/index.js +3 -2
  67. package/dist/schemas/index.d.ts +28 -8
  68. package/dist/schemas/index.js +36 -8
  69. package/dist/spatial/index.d.ts +9 -4
  70. package/dist/spatial/index.js +1 -1
  71. package/dist/storage/index.d.ts +2 -2
  72. package/dist/storage/index.js +1 -1
  73. package/dist/story/index.d.ts +1021 -41
  74. package/dist/story/index.js +135 -8
  75. package/dist/{themeLibrary-BHjJ0Qx6.d.ts → themeLibrary-CehcJhzz.d.ts} +1 -1
  76. package/dist/timing/index.js +3 -5
  77. package/dist/transform/index.d.ts +48 -10
  78. package/dist/transform/index.js +17 -481
  79. package/dist/transform/index.js.map +1 -1
  80. package/dist/{types-BdZkdJ3z.d.ts → types-DlAZ7MW4.d.ts} +115 -1
  81. package/dist/versions/index.d.ts +1 -1
  82. package/package.json +6 -1
  83. package/src/__tests__/animationUtils.test.ts +40 -0
  84. package/src/__tests__/annotationCoercion.test.ts +243 -0
  85. package/src/__tests__/applyRenderStyle.test.ts +146 -0
  86. package/src/__tests__/atmosphereLayers.test.ts +108 -0
  87. package/src/__tests__/attrTokens.test.ts +162 -0
  88. package/src/__tests__/contentExtractor.test.ts +60 -0
  89. package/src/__tests__/coverBlock.test.ts +46 -1
  90. package/src/__tests__/customTemplateSample.test.ts +144 -0
  91. package/src/__tests__/customTemplatesFrontmatter.test.ts +236 -0
  92. package/src/__tests__/customThemesFrontmatter.test.ts +145 -0
  93. package/src/__tests__/diagramBlock.test.ts +122 -0
  94. package/src/__tests__/diagramLayout.test.ts +134 -0
  95. package/src/__tests__/drawing.test.ts +109 -0
  96. package/src/__tests__/drawingBlock.test.ts +124 -0
  97. package/src/__tests__/drawingLayout.test.ts +171 -0
  98. package/src/__tests__/geohash.test.ts +6 -5
  99. package/src/__tests__/getLayers.test.ts +6 -2
  100. package/src/__tests__/getLayersFallback.test.ts +103 -0
  101. package/src/__tests__/imageEditExportSvg.test.ts +70 -0
  102. package/src/__tests__/imageEditVersions.test.ts +72 -0
  103. package/src/__tests__/inlineIconMarker.test.ts +36 -0
  104. package/src/__tests__/inputDescriptors.test.ts +162 -0
  105. package/src/__tests__/jsonForm.tokens.test.ts +84 -0
  106. package/src/__tests__/layoutBlock.test.ts +83 -0
  107. package/src/__tests__/layoutLayout.test.ts +169 -0
  108. package/src/__tests__/markdown.test.ts +87 -0
  109. package/src/__tests__/markdownSanitize.test.ts +79 -0
  110. package/src/__tests__/markdownToDoc.test.ts +391 -2
  111. package/src/__tests__/mediaAnnotations.test.ts +91 -0
  112. package/src/__tests__/mediaSchedule.test.ts +118 -0
  113. package/src/__tests__/pandocAttrParse.test.ts +187 -0
  114. package/src/__tests__/resolveDocTheme.test.ts +55 -0
  115. package/src/__tests__/rootExports.test.ts +43 -0
  116. package/src/__tests__/shapeGeometry.test.ts +89 -0
  117. package/src/__tests__/shapeVocabulary.test.ts +133 -0
  118. package/src/__tests__/structuredData.test.ts +249 -0
  119. package/src/__tests__/templateAnnotationParse.test.ts +108 -0
  120. package/src/__tests__/templateMetadata.test.ts +31 -0
  121. package/src/__tests__/templates.test.ts +257 -4
  122. package/src/__tests__/themeCompile.test.ts +42 -0
  123. package/src/__tests__/themeValidator.test.ts +6 -0
  124. package/src/__tests__/transformV2.test.ts +137 -0
  125. package/src/__tests__/transitionNormalize.test.ts +61 -0
  126. package/src/__tests__/validateDoc.test.ts +260 -0
  127. package/src/doc/annotationBlocks.ts +117 -0
  128. package/src/doc/customTemplatesFrontmatter.ts +314 -0
  129. package/src/doc/customThemesFrontmatter.ts +95 -0
  130. package/src/doc/docToMarkdown.ts +104 -5
  131. package/src/doc/getLayers.ts +49 -17
  132. package/src/doc/index.ts +47 -1
  133. package/src/doc/markdownToDoc.ts +411 -13
  134. package/src/doc/mediaAnnotations.ts +100 -0
  135. package/src/doc/resolveDocTheme.ts +55 -0
  136. package/src/doc/standaloneAnnotation.ts +58 -0
  137. package/src/doc/structuredData.ts +301 -0
  138. package/src/doc/templateInputs.ts +240 -0
  139. package/src/doc/templates/__tests__/customTemplate.test.ts +170 -0
  140. package/src/doc/templates/__tests__/customTemplateRegistry.test.ts +110 -0
  141. package/src/doc/templates/accentImage.ts +30 -6
  142. package/src/doc/templates/captionUtils.ts +23 -0
  143. package/src/doc/templates/comparisonBar.ts +33 -18
  144. package/src/doc/templates/coverBlock.ts +35 -5
  145. package/src/doc/templates/customTemplate.ts +51 -0
  146. package/src/doc/templates/dataTable.ts +37 -19
  147. package/src/doc/templates/dateEvent.ts +41 -20
  148. package/src/doc/templates/definitionCard.ts +26 -18
  149. package/src/doc/templates/diagramBlock.ts +204 -0
  150. package/src/doc/templates/diagramLayout.ts +159 -0
  151. package/src/doc/templates/drawingBlock.ts +325 -0
  152. package/src/doc/templates/drawingLayout.ts +539 -0
  153. package/src/doc/templates/factCard.ts +54 -26
  154. package/src/doc/templates/fallbackBlock.ts +123 -0
  155. package/src/doc/templates/featureBlock.ts +20 -13
  156. package/src/doc/templates/fullBleedQuote.ts +18 -8
  157. package/src/doc/templates/imageWithCaption.ts +30 -15
  158. package/src/doc/templates/index.ts +225 -46
  159. package/src/doc/templates/inputDescriptors.ts +432 -0
  160. package/src/doc/templates/layoutBlock.ts +43 -0
  161. package/src/doc/templates/layoutLayout.ts +243 -0
  162. package/src/doc/templates/listBlock.ts +56 -30
  163. package/src/doc/templates/mapBlock.ts +15 -12
  164. package/src/doc/templates/metadata.ts +120 -0
  165. package/src/doc/templates/persistentLayers.ts +129 -11
  166. package/src/doc/templates/photoGrid.ts +22 -34
  167. package/src/doc/templates/pullQuote.ts +30 -12
  168. package/src/doc/templates/quoteBlock.ts +54 -29
  169. package/src/doc/templates/sectionHeader.ts +25 -11
  170. package/src/doc/templates/statHighlight.ts +39 -22
  171. package/src/doc/templates/titleBlock.ts +59 -21
  172. package/src/doc/templates/tokens/__tests__/resolveTokens.test.ts +487 -0
  173. package/src/doc/templates/tokens/resolveTokens.ts +453 -0
  174. package/src/doc/templates/twoColumn.ts +48 -30
  175. package/src/doc/templates/videoPullQuote.ts +22 -12
  176. package/src/doc/templates/videoWithCaption.ts +21 -15
  177. package/src/doc/utils/animationUtils.ts +267 -3
  178. package/src/doc/utils/applyRenderStyle.ts +113 -0
  179. package/src/doc/utils/imageTreatment.ts +70 -0
  180. package/src/doc/utils/nearest.ts +59 -0
  181. package/src/doc/utils/shapeGeometry.ts +464 -0
  182. package/src/doc/utils/themeUtils.ts +75 -1
  183. package/src/doc/validate.ts +575 -0
  184. package/src/generate/contentExtractor.ts +148 -35
  185. package/src/generate/index.ts +0 -3
  186. package/src/icons/index.ts +8 -0
  187. package/src/icons/inlineIconMarker.ts +73 -0
  188. package/src/imageEdit/export.ts +85 -2
  189. package/src/imageEdit/index.ts +1 -0
  190. package/src/imageEdit/persistence.ts +51 -0
  191. package/src/index.ts +2 -0
  192. package/src/jsonForm/index.ts +2 -0
  193. package/src/jsonForm/tokens.ts +78 -0
  194. package/src/markdown/annotationCoercion.ts +295 -0
  195. package/src/markdown/attrTokens.ts +241 -0
  196. package/src/markdown/convert.ts +229 -26
  197. package/src/markdown/index.ts +37 -0
  198. package/src/markdown/sanitize.ts +318 -0
  199. package/src/markdown/stringify.ts +96 -6
  200. package/src/markdown/types.ts +66 -0
  201. package/src/markdown/utils.ts +111 -2
  202. package/src/recommend/templates.ts +24 -0
  203. package/src/schemas/BlockTemplates.ts +133 -4
  204. package/src/schemas/CustomTemplates.ts +126 -0
  205. package/src/schemas/Doc.ts +387 -65
  206. package/src/schemas/ImageEditDoc.ts +11 -4
  207. package/src/schemas/Media.ts +169 -0
  208. package/src/schemas/Theme.ts +23 -1
  209. package/src/schemas/Transitions.ts +224 -0
  210. package/src/schemas/colorUtils.ts +33 -0
  211. package/src/schemas/fontStacks.ts +5 -2
  212. package/src/schemas/index.ts +3 -0
  213. package/src/schemas/themeCompile.ts +24 -5
  214. package/src/schemas/themeValidator.ts +49 -11
  215. package/src/schemas/themes/cinematic.json +69 -12
  216. package/src/schemas/themes/documentary.json +58 -11
  217. package/src/schemas/themes/gezellig.json +60 -11
  218. package/src/schemas/themes/magazine.json +54 -9
  219. package/src/schemas/themes/morning-light.json +52 -9
  220. package/src/schemas/themes/standard-dark.json +48 -13
  221. package/src/schemas/themes/standard.json +44 -12
  222. package/src/schemas/themes/tech-dark.json +65 -12
  223. package/src/spatial/Geohash.ts +52 -21
  224. package/src/storage/ContentContainer.ts +6 -2
  225. package/src/transform/applyTransform.ts +43 -4
  226. package/src/transform/index.ts +1 -0
  227. package/src/transform/registry.ts +44 -13
  228. package/src/transform/styles/dataDriven.ts +2 -1
  229. package/src/transform/styles/documentary.ts +2 -1
  230. package/src/transform/styles/magazine.ts +3 -1
  231. package/src/transform/styles/minimal.ts +2 -1
  232. package/src/transform/styles/narrative.ts +3 -1
  233. package/src/transform/templateSelector.ts +87 -11
  234. package/src/transform/types.ts +35 -2
  235. package/dist/chunk-2TMAWB4Y.js.map +0 -1
  236. package/dist/chunk-3E5F2XMR.js.map +0 -1
  237. package/dist/chunk-3K5OG6KI.js.map +0 -1
  238. package/dist/chunk-4X3JQXNM.js +0 -35
  239. package/dist/chunk-4X3JQXNM.js.map +0 -1
  240. package/dist/chunk-57CVQCFX.js.map +0 -1
  241. package/dist/chunk-5WFRKURX.js.map +0 -1
  242. package/dist/chunk-7UDSRZKG.js.map +0 -1
  243. package/dist/chunk-B4EEON3N.js.map +0 -1
  244. package/dist/chunk-FR2RBTKO.js.map +0 -1
  245. package/dist/chunk-H3AXU4MJ.js +0 -179
  246. package/dist/chunk-H3AXU4MJ.js.map +0 -1
  247. package/dist/chunk-K32VJONL.js.map +0 -1
  248. package/dist/chunk-KSWJR66U.js +0 -3706
  249. package/dist/chunk-KSWJR66U.js.map +0 -1
  250. package/dist/chunk-MYIH7FWD.js +0 -145
  251. package/dist/chunk-MYIH7FWD.js.map +0 -1
  252. package/dist/chunk-ZTX4PFFT.js.map +0 -1
  253. package/src/__tests__/slideshowGenerator.test.ts +0 -128
  254. package/src/generate/slideshowGenerator.ts +0 -273
@@ -17,18 +17,21 @@
17
17
 
18
18
  import type { Layer } from '../../schemas/Doc.js';
19
19
  import type { VideoPullQuoteInput, TemplateContext } from '../../schemas/BlockTemplates.js';
20
- import { scaledFontSize } from '../../schemas/BlockTemplates.js';
21
- import { getThemeFont } from '../utils/themeUtils.js';
20
+ import { getThemeFont, themedEntrance, themedFontSize } from '../utils/themeUtils.js';
21
+ import { estimateTextHeight } from './captionUtils.js';
22
22
 
23
23
  export function videoPullQuote(input: VideoPullQuoteInput, context: TemplateContext): Layer[] {
24
24
  const { text, attribution, backgroundVideo } = input;
25
+ const { viewport } = context;
25
26
 
26
27
  // Guard: backgroundVideo is required
27
28
  if (!backgroundVideo?.src) return [];
28
29
 
29
- const quoteFontSize = scaledFontSize(52, context, true);
30
- const attrFontSize = scaledFontSize(24, context, false);
31
- const decoFontSize = scaledFontSize(200, context, true);
30
+ const quoteFontSize = themedFontSize(52, context, true);
31
+ const attrFontSize = themedFontSize(26, context, false);
32
+ const decoFontSize = themedFontSize(200, context, true);
33
+ const quoteLineHeight = 1.4;
34
+ const quoteYPct = attribution ? 45 : 50;
32
35
 
33
36
  const layers: Layer[] = [
34
37
  // Full-bleed background video clip
@@ -90,22 +93,29 @@ export function videoPullQuote(input: VideoPullQuoteInput, context: TemplateCont
90
93
  fontFamily: getThemeFont(context, 'title'),
91
94
  color: '#ffffff',
92
95
  textAlign: 'center',
93
- lineHeight: 1.5,
96
+ lineHeight: quoteLineHeight,
94
97
  shadow: true,
95
98
  },
96
99
  },
97
100
  position: {
98
101
  x: '50%',
99
- y: attribution ? '45%' : '50%',
102
+ y: `${quoteYPct}%`,
100
103
  anchor: 'center',
101
- width: '80%',
104
+ width: '72%',
102
105
  },
103
- animation: { type: 'fadeIn', duration: 2 },
106
+ animation: themedEntrance(context, 'text', { type: 'fadeIn', duration: 2 }),
104
107
  },
105
108
  ];
106
109
 
107
- // Attribution
110
+ // Attribution \u2014 hangs just below the quote's estimated bottom edge so
111
+ // the two read as one lockup instead of stranding near the bottom.
108
112
  if (attribution) {
113
+ const quoteWidthPx = 0.72 * viewport.width;
114
+ const quoteHeightPx = estimateTextHeight(text, quoteFontSize, quoteWidthPx, quoteLineHeight);
115
+ const attrYPct = Math.min(
116
+ 82,
117
+ quoteYPct + ((quoteHeightPx / 2 + quoteFontSize * 1.7) / viewport.height) * 100,
118
+ );
109
119
  layers.push({
110
120
  type: 'text',
111
121
  id: 'attribution',
@@ -114,14 +124,14 @@ export function videoPullQuote(input: VideoPullQuoteInput, context: TemplateCont
114
124
  style: {
115
125
  fontSize: attrFontSize,
116
126
  fontFamily: getThemeFont(context, 'body'),
117
- color: 'rgba(255, 255, 255, 0.7)',
127
+ color: 'rgba(255, 255, 255, 0.85)',
118
128
  textAlign: 'center',
119
129
  shadow: true,
120
130
  },
121
131
  },
122
132
  position: {
123
133
  x: '50%',
124
- y: '72%',
134
+ y: `${attrYPct}%`,
125
135
  anchor: 'center',
126
136
  },
127
137
  animation: { type: 'fadeIn', duration: 1, delay: 1.5 },
@@ -17,8 +17,8 @@
17
17
 
18
18
  import type { Layer } from '../../schemas/Doc.js';
19
19
  import type { VideoWithCaptionInput, TemplateContext } from '../../schemas/BlockTemplates.js';
20
- import { scaledFontSize } from '../../schemas/BlockTemplates.js';
21
- import { getThemeFont } from '../utils/themeUtils.js';
20
+ import { getThemeFont, shouldUseShadow, themedFontSize } from '../utils/themeUtils.js';
21
+ import { withAlpha } from '../../schemas/colorUtils.js';
22
22
  import { cleanCaption } from './captionUtils.js';
23
23
 
24
24
  export function videoWithCaption(input: VideoWithCaptionInput, context: TemplateContext): Layer[] {
@@ -35,10 +35,10 @@ export function videoWithCaption(input: VideoWithCaptionInput, context: Template
35
35
  videoLicense,
36
36
  } = input;
37
37
  const caption = rawCaption ? cleanCaption(rawCaption) : rawCaption;
38
- const { theme, layout } = context;
38
+ const { theme } = context;
39
39
 
40
- const captionFontSize = scaledFontSize(36, context, false);
41
- const creditFontSize = scaledFontSize(16, context, false);
40
+ const captionFontSize = themedFontSize(30, context, false);
41
+ const creditFontSize = themedFontSize(16, context, false);
42
42
 
43
43
  const layers: Layer[] = [
44
44
  // Background video clip
@@ -60,26 +60,30 @@ export function videoWithCaption(input: VideoWithCaptionInput, context: Template
60
60
  },
61
61
  ];
62
62
 
63
- // Caption text at bottom with gradient for readability
63
+ // Caption text with a compact translucent band. Long archival captions are
64
+ // clamped so they don't collide with player controls or cover the frame.
64
65
  if (caption) {
66
+ const bg = theme.colors.background;
67
+ const captionBgY =
68
+ captionPosition === 'top' ? '7%' : captionPosition === 'center' ? '42%' : '68%';
69
+ const captionY =
70
+ captionPosition === 'top' ? '13%' : captionPosition === 'center' ? '48%' : '74%';
71
+
65
72
  layers.push({
66
73
  type: 'shape',
67
74
  id: 'caption-gradient',
68
75
  content: {
69
76
  shape: 'rect',
70
- fill: 'linear-gradient(0deg, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.3) 60%, transparent 100%)',
77
+ fill: `linear-gradient(90deg, ${withAlpha(bg, 0)}, ${withAlpha(bg, 0.78)} 16%, ${withAlpha(bg, 0.78)} 84%, ${withAlpha(bg, 0)})`,
71
78
  },
72
79
  position: {
73
80
  x: 0,
74
- y: captionPosition === 'top' ? 0 : '65%',
81
+ y: captionBgY,
75
82
  width: '100%',
76
- height: '35%',
83
+ height: '16%',
77
84
  },
78
85
  });
79
86
 
80
- const captionY =
81
- captionPosition === 'top' ? '15%' : captionPosition === 'center' ? '50%' : '82%';
82
-
83
87
  layers.push({
84
88
  type: 'text',
85
89
  id: 'caption',
@@ -90,14 +94,16 @@ export function videoWithCaption(input: VideoWithCaptionInput, context: Template
90
94
  fontFamily: getThemeFont(context, 'body'),
91
95
  color: theme.colors.text,
92
96
  textAlign: 'center',
93
- shadow: true,
97
+ shadow: shouldUseShadow(context),
98
+ lineHeight: 1.18,
99
+ maxLines: 2,
94
100
  },
95
101
  },
96
102
  position: {
97
103
  x: '50%',
98
104
  y: captionY,
99
105
  anchor: 'center',
100
- width: layout.maxTextWidth,
106
+ width: '78%',
101
107
  },
102
108
  animation: { type: 'fadeIn', duration: 1.5, delay: 0.5 },
103
109
  });
@@ -113,7 +119,7 @@ export function videoWithCaption(input: VideoWithCaptionInput, context: Template
113
119
  style: {
114
120
  fontSize: creditFontSize,
115
121
  fontFamily: getThemeFont(context, 'body'),
116
- color: 'rgba(255, 255, 255, 0.5)',
122
+ color: withAlpha(theme.colors.text, 0.65),
117
123
  textAlign: 'right',
118
124
  },
119
125
  },
@@ -8,7 +8,12 @@
8
8
  * This is shared code used by both site and efb-app doc renderers.
9
9
  */
10
10
 
11
- import type { Animation, AnimationType, TransitionType } from '../../schemas/Doc.js';
11
+ import type {
12
+ Animation,
13
+ AnimationType,
14
+ TransitionDirection,
15
+ TransitionType,
16
+ } from '../../schemas/Doc.js';
12
17
 
13
18
  interface AnimationResult {
14
19
  /** CSS class name to apply */
@@ -112,9 +117,268 @@ export function getDefaultAnimationDuration(type: AnimationType): number {
112
117
  /**
113
118
  * Get transition class for slide entry/exit.
114
119
  */
115
- export function getTransitionClass(type: TransitionType, entering: boolean): string {
120
+ export function getTransitionClass(
121
+ type: TransitionType,
122
+ entering: boolean,
123
+ direction?: TransitionDirection,
124
+ ): string {
125
+ if (type === 'cut') return '';
116
126
  const mode = entering ? 'enter' : 'exit';
117
- return `transition-${type}-${mode}`;
127
+ return `transition-${getTransitionVisualClass(type, direction)}-${mode}`;
128
+ }
129
+
130
+ type TransitionVisualClass =
131
+ | 'cut'
132
+ | 'fade'
133
+ | 'dissolve'
134
+ | 'pushLeft'
135
+ | 'pushRight'
136
+ | 'pushUp'
137
+ | 'pushDown'
138
+ | 'wipeLeft'
139
+ | 'wipeRight'
140
+ | 'wipeUp'
141
+ | 'wipeDown'
142
+ | 'splitHorizontal'
143
+ | 'splitVertical'
144
+ | 'revealLeft'
145
+ | 'revealRight'
146
+ | 'revealUp'
147
+ | 'revealDown'
148
+ | 'random'
149
+ | 'randomBarsHorizontal'
150
+ | 'randomBarsVertical'
151
+ | 'shapeCircle'
152
+ | 'shapeDiamond'
153
+ | 'shapePlus'
154
+ | 'uncoverLeft'
155
+ | 'uncoverRight'
156
+ | 'uncoverUp'
157
+ | 'uncoverDown'
158
+ | 'coverLeft'
159
+ | 'coverRight'
160
+ | 'coverUp'
161
+ | 'coverDown'
162
+ | 'zoom'
163
+ | 'panLeft'
164
+ | 'panRight'
165
+ | 'panUp'
166
+ | 'panDown'
167
+ | 'flash'
168
+ | 'newsflash'
169
+ | 'fallOver'
170
+ | 'drape'
171
+ | 'curtains'
172
+ | 'wind'
173
+ | 'prestige'
174
+ | 'fracture'
175
+ | 'crush'
176
+ | 'peelOff'
177
+ | 'pageCurl'
178
+ | 'pageCurlDouble'
179
+ | 'pageCurlSingle'
180
+ | 'airplane'
181
+ | 'origami'
182
+ | 'checkerboard'
183
+ | 'blindsHorizontal'
184
+ | 'blindsVertical'
185
+ | 'clock'
186
+ | 'ripple'
187
+ | 'honeycomb'
188
+ | 'glitter'
189
+ | 'vortex'
190
+ | 'shred'
191
+ | 'switch'
192
+ | 'flip'
193
+ | 'gallery'
194
+ | 'cube'
195
+ | 'doors'
196
+ | 'box'
197
+ | 'ferrisWheel'
198
+ | 'conveyor'
199
+ | 'rotate'
200
+ | 'window'
201
+ | 'orbit'
202
+ | 'flyThrough'
203
+ | 'morph'
204
+ | 'combHorizontal'
205
+ | 'combVertical'
206
+ | 'pullLeft'
207
+ | 'pullRight'
208
+ | 'pullUp'
209
+ | 'pullDown'
210
+ | 'stripsLeft'
211
+ | 'stripsRight'
212
+ | 'stripsUp'
213
+ | 'stripsDown'
214
+ | 'wedge'
215
+ | 'wheel'
216
+ | 'wheelReverse'
217
+ | 'prism'
218
+ | 'warp'
219
+ | 'slideLeft'
220
+ | 'slideRight'
221
+ | 'slideUp'
222
+ | 'slideDown';
223
+
224
+ const TRANSITION_VISUAL_CLASS_BY_TYPE: Record<TransitionType, TransitionVisualClass> = {
225
+ airplane: 'airplane',
226
+ blinds: 'blindsHorizontal',
227
+ box: 'box',
228
+ checker: 'checkerboard',
229
+ checkerboard: 'checkerboard',
230
+ circle: 'shapeCircle',
231
+ clock: 'clock',
232
+ comb: 'combHorizontal',
233
+ conveyor: 'conveyor',
234
+ cover: 'coverLeft',
235
+ crush: 'crush',
236
+ cube: 'cube',
237
+ curtains: 'curtains',
238
+ cut: 'cut',
239
+ diamond: 'shapeDiamond',
240
+ dissolve: 'dissolve',
241
+ doors: 'doors',
242
+ drape: 'drape',
243
+ fade: 'fade',
244
+ fallOver: 'fallOver',
245
+ ferris: 'ferrisWheel',
246
+ ferrisWheel: 'ferrisWheel',
247
+ flash: 'flash',
248
+ flip: 'flip',
249
+ flyThrough: 'flyThrough',
250
+ flythrough: 'flyThrough',
251
+ fracture: 'fracture',
252
+ gallery: 'gallery',
253
+ glitter: 'glitter',
254
+ honeycomb: 'honeycomb',
255
+ morph: 'morph',
256
+ newsflash: 'newsflash',
257
+ orbit: 'orbit',
258
+ origami: 'origami',
259
+ pageCurl: 'pageCurl',
260
+ pageCurlDouble: 'pageCurlDouble',
261
+ pageCurlSingle: 'pageCurlSingle',
262
+ pan: 'panLeft',
263
+ peelOff: 'peelOff',
264
+ plus: 'shapePlus',
265
+ prestige: 'prestige',
266
+ prism: 'prism',
267
+ pull: 'pullLeft',
268
+ push: 'pushLeft',
269
+ random: 'random',
270
+ randomBar: 'randomBarsHorizontal',
271
+ randomBars: 'randomBarsVertical',
272
+ reveal: 'revealLeft',
273
+ ripple: 'ripple',
274
+ rotate: 'rotate',
275
+ shape: 'shapeCircle',
276
+ shred: 'shred',
277
+ slideDown: 'slideDown',
278
+ slideLeft: 'slideLeft',
279
+ slideRight: 'slideRight',
280
+ slideUp: 'slideUp',
281
+ split: 'splitHorizontal',
282
+ strips: 'stripsLeft',
283
+ switch: 'switch',
284
+ uncover: 'uncoverLeft',
285
+ vortex: 'vortex',
286
+ warp: 'warp',
287
+ wedge: 'wedge',
288
+ wheel: 'wheel',
289
+ wheelReverse: 'wheelReverse',
290
+ wind: 'wind',
291
+ window: 'window',
292
+ wipe: 'wipeLeft',
293
+ zoom: 'zoom',
294
+ };
295
+
296
+ function getTransitionVisualClass(
297
+ type: TransitionType,
298
+ direction?: TransitionDirection,
299
+ ): TransitionVisualClass {
300
+ switch (type) {
301
+ case 'randomBars':
302
+ case 'randomBar':
303
+ return axisClass('randomBars', direction, type === 'randomBar' ? 'Horizontal' : 'Vertical');
304
+ case 'comb':
305
+ return axisClass('comb', direction, 'Horizontal');
306
+ case 'blinds':
307
+ return axisClass('blinds', direction, 'Horizontal');
308
+ case 'split':
309
+ return axisClass('split', direction, 'Horizontal');
310
+ case 'push':
311
+ return directionalClass('push', direction, 'Left');
312
+ case 'wipe':
313
+ return directionalClass('wipe', direction, 'Left');
314
+ case 'cover':
315
+ return directionalClass('cover', direction, 'Left');
316
+ case 'uncover':
317
+ return directionalClass('uncover', direction, 'Left');
318
+ case 'pull':
319
+ return directionalClass('pull', direction, 'Left');
320
+ case 'reveal':
321
+ return directionalClass('reveal', direction, 'Left');
322
+ case 'strips':
323
+ return directionalClass('strips', direction, 'Left');
324
+ case 'pan':
325
+ return directionalClass('pan', direction, 'Left');
326
+ default:
327
+ return TRANSITION_VISUAL_CLASS_BY_TYPE[type];
328
+ }
329
+ }
330
+
331
+ function directionalClass(
332
+ prefix: 'cover' | 'pan' | 'pull' | 'push' | 'reveal' | 'strips' | 'uncover' | 'wipe',
333
+ direction: TransitionDirection | undefined,
334
+ fallback: 'Left' | 'Right' | 'Up' | 'Down',
335
+ ): TransitionVisualClass {
336
+ const suffix = directionSuffix(direction, fallback);
337
+ return `${prefix}${suffix}` as TransitionVisualClass;
338
+ }
339
+
340
+ function axisClass(
341
+ prefix: 'blinds' | 'comb' | 'randomBars' | 'split',
342
+ direction: TransitionDirection | undefined,
343
+ fallback: 'Horizontal' | 'Vertical',
344
+ ): TransitionVisualClass {
345
+ const suffix = axisSuffix(direction, fallback);
346
+ return `${prefix}${suffix}` as TransitionVisualClass;
347
+ }
348
+
349
+ function directionSuffix(
350
+ direction: TransitionDirection | undefined,
351
+ fallback: 'Left' | 'Right' | 'Up' | 'Down',
352
+ ): 'Left' | 'Right' | 'Up' | 'Down' {
353
+ switch (direction) {
354
+ case 'right':
355
+ return 'Right';
356
+ case 'up':
357
+ return 'Up';
358
+ case 'down':
359
+ return 'Down';
360
+ case 'left':
361
+ default:
362
+ return fallback;
363
+ }
364
+ }
365
+
366
+ function axisSuffix(
367
+ direction: TransitionDirection | undefined,
368
+ fallback: 'Horizontal' | 'Vertical',
369
+ ): 'Horizontal' | 'Vertical' {
370
+ switch (direction) {
371
+ case 'vertical':
372
+ case 'up':
373
+ case 'down':
374
+ return 'Vertical';
375
+ case 'horizontal':
376
+ case 'left':
377
+ case 'right':
378
+ return 'Horizontal';
379
+ default:
380
+ return fallback;
381
+ }
118
382
  }
119
383
 
120
384
  /**
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Render-style post-pass — applies a theme's mechanical motion defaults to
3
+ * template-generated layers.
4
+ *
5
+ * This is the intent-free half of theme motion (see `themedEntrance` in
6
+ * [themeUtils.ts](./themeUtils.ts) for the intent-bearing half): it never
7
+ * decides *what kind* of entrance a layer gets, only
8
+ *
9
+ * 1. scales existing animation durations/delays by the theme's
10
+ * `style.animationSpeed` multiplier (1.4 = calmer/slower, 0.7 = snappier,
11
+ * 1.0 = no-op), and
12
+ * 2. gives full-bleed cover imagery with no authored animation a gentle,
13
+ * deterministic Ken Burns when the theme opts into ambient motion.
14
+ *
15
+ * Applied ONLY to template-generated layers — raw authored `block.layers`
16
+ * are never touched, and an explicit `animation` (including
17
+ * `{ type: 'none' }`) always wins.
18
+ */
19
+
20
+ import type { Layer, Animation } from '../../schemas/Doc.js';
21
+ import type { Theme } from '../../schemas/Theme.js';
22
+ import { SeededRandom, hashString } from '../../random/index.js';
23
+
24
+ /** The gentle Ken Burns variants ambient motion rotates between. */
25
+ const AMBIENT_VARIANTS: readonly Animation[] = [
26
+ { type: 'slowZoom', direction: 'in' },
27
+ { type: 'slowZoom', direction: 'out' },
28
+ { type: 'slowZoom', panDirection: 'left' },
29
+ { type: 'slowZoom', panDirection: 'right' },
30
+ ];
31
+
32
+ /** Minimum ambient Ken Burns duration — short blocks shouldn't rush the pan. */
33
+ const MIN_AMBIENT_DURATION_SECONDS = 8;
34
+
35
+ function isZeroish(v: number | string | undefined): boolean {
36
+ return v === 0 || v === '0' || v === '0%';
37
+ }
38
+
39
+ /**
40
+ * Full-bleed cover imagery is the only shape we can safely blanket-animate:
41
+ * anything smaller (strips, insets, grid cells) was placed deliberately and
42
+ * a zoom would fight the composition.
43
+ */
44
+ function isFullBleedCoverMedia(layer: Layer): boolean {
45
+ if (layer.type !== 'image' && layer.type !== 'video') return false;
46
+ const { position } = layer;
47
+ if (!isZeroish(position.x) || !isZeroish(position.y)) return false;
48
+ if (position.width !== '100%' || position.height !== '100%') return false;
49
+ const fit = (layer.content as { fit?: string }).fit;
50
+ return fit === undefined || fit === 'cover';
51
+ }
52
+
53
+ /**
54
+ * Whether the theme asks for ambient Ken Burns on otherwise-static imagery.
55
+ * `defaultImageAnimation: 'slowZoom'` is treated as an alias for the boolean
56
+ * (that's what themes authoring it were reaching for).
57
+ */
58
+ export function themeWantsAmbientMotion(theme: Theme): boolean {
59
+ return (
60
+ theme.renderStyle.ambientMotion === true ||
61
+ theme.renderStyle.defaultImageAnimation === 'slowZoom'
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Apply the theme's mechanical motion defaults to a block's
67
+ * template-generated layers. Pure: returns new layer objects where a change
68
+ * applies and the original objects where none does.
69
+ *
70
+ * @param layers Template-generated layers (never raw authored layers)
71
+ * @param block The source block — supplies the ambient-motion seed and duration
72
+ * @param theme Resolved theme
73
+ */
74
+ export function applyRenderStyleToLayers(
75
+ layers: Layer[],
76
+ block: { id: string; duration?: number },
77
+ theme: Theme,
78
+ ): Layer[] {
79
+ const speed = theme.style.animationSpeed ?? 1.0;
80
+ const ambient = themeWantsAmbientMotion(theme);
81
+ if (speed === 1.0 && !ambient) return layers;
82
+
83
+ return layers.map((layer) => {
84
+ let animation = layer.animation;
85
+
86
+ // Ambient Ken Burns for full-bleed cover imagery with no authored
87
+ // animation. Deterministic per block+layer so re-renders, export
88
+ // frames, and screenshots always agree.
89
+ if (!animation && ambient && isFullBleedCoverMedia(layer)) {
90
+ const rng = new SeededRandom(hashString(`${block.id}:${layer.id}`));
91
+ const variant = rng.pickRequired(AMBIENT_VARIANTS as Animation[]);
92
+ animation = {
93
+ ...variant,
94
+ duration: Math.max(MIN_AMBIENT_DURATION_SECONDS, block.duration ?? 0),
95
+ };
96
+ }
97
+
98
+ if (!animation || animation.type === 'none') {
99
+ return animation === layer.animation ? layer : { ...layer, animation };
100
+ }
101
+
102
+ // Speed scaling — single site so templates never double-scale.
103
+ if (speed !== 1.0) {
104
+ animation = {
105
+ ...animation,
106
+ ...(animation.duration != null ? { duration: animation.duration * speed } : {}),
107
+ ...(animation.delay != null ? { delay: animation.delay * speed } : {}),
108
+ };
109
+ }
110
+
111
+ return animation === layer.animation ? layer : { ...layer, animation };
112
+ });
113
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Image treatment → CSS filter derivation.
3
+ *
4
+ * Treatments are theme-level photographic grades (see `ImageTreatment` in
5
+ * [Doc.ts](../../schemas/Doc.ts)). They compile to plain CSS `filter`
6
+ * functions so the same string works on a foreignObject `<img>`, an SVG
7
+ * `<image>` element, and in headless frame capture — no SVG filter defs to
8
+ * coordinate, no renderer-specific code paths.
9
+ */
10
+
11
+ import type { ImageTreatment } from '../../schemas/Doc.js';
12
+ import { hexHueDegrees } from '../../schemas/colorUtils.js';
13
+
14
+ /** Sepia's intrinsic tint hue (deg) — duotone rotates from here to the target. */
15
+ const SEPIA_HUE_DEGREES = 40;
16
+
17
+ const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
18
+ const r2 = (v: number) => Math.round(v * 100) / 100;
19
+
20
+ /**
21
+ * Build the CSS `filter` value for a treatment and/or blur.
22
+ * Returns `undefined` when there is nothing to apply.
23
+ */
24
+ export function cssFilterForTreatment(
25
+ treatment?: ImageTreatment,
26
+ blur?: number,
27
+ ): string | undefined {
28
+ const parts: string[] = [];
29
+
30
+ if (blur != null && blur > 0) {
31
+ parts.push(`blur(${Math.round(blur)}px)`);
32
+ }
33
+
34
+ if (treatment && treatment.type !== 'none') {
35
+ const s = clamp01(treatment.strength ?? 0.6);
36
+ switch (treatment.type) {
37
+ case 'mono':
38
+ // Archival black & white with a whisper of contrast to keep depth.
39
+ parts.push(`grayscale(${r2(s)})`, `contrast(${r2(1 + 0.05 * s)})`);
40
+ break;
41
+ case 'duotone': {
42
+ // Classic CSS duotone approximation: flatten to gray, tint through
43
+ // sepia (intrinsic hue ~40°), rotate to the target hue, re-saturate.
44
+ const hue = hexHueDegrees(treatment.color ?? '#3d5a80');
45
+ parts.push(
46
+ 'grayscale(1)',
47
+ `sepia(${r2(Math.max(0.35, s))})`,
48
+ `hue-rotate(${Math.round(hue - SEPIA_HUE_DEGREES)}deg)`,
49
+ `saturate(${r2(1 + s)})`,
50
+ );
51
+ break;
52
+ }
53
+ case 'warm':
54
+ // Partial sepia is a well-behaved warmer; slight saturation lift.
55
+ parts.push(`sepia(${r2(0.4 * s)})`, `saturate(${r2(1 + 0.15 * s)})`);
56
+ break;
57
+ case 'cool':
58
+ // Desaturate a touch and nudge warm tones toward magenta/blue —
59
+ // small rotations read as a grade, large ones wreck hues.
60
+ parts.push(
61
+ `saturate(${r2(1 - 0.25 * s)})`,
62
+ `hue-rotate(${Math.round(-12 * s)}deg)`,
63
+ `contrast(${r2(1 + 0.06 * s)})`,
64
+ );
65
+ break;
66
+ }
67
+ }
68
+
69
+ return parts.length > 0 ? parts.join(' ') : undefined;
70
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Shared "did-you-mean" edit-distance helpers.
3
+ *
4
+ * A leaf module (no imports) so both `doc/validate.ts` and
5
+ * `doc/templates/inputDescriptors.ts` can share one implementation without
6
+ * risking an import cycle through `templates/index.ts`.
7
+ */
8
+
9
+ /** Levenshtein edit distance between two strings. */
10
+ export function levenshtein(a: string, b: string): number {
11
+ if (a === b) return 0;
12
+ const m = a.length;
13
+ const n = b.length;
14
+ if (m === 0) return n;
15
+ if (n === 0) return m;
16
+ let prev = new Array<number>(n + 1);
17
+ let curr = new Array<number>(n + 1);
18
+ for (let j = 0; j <= n; j++) prev[j] = j;
19
+ for (let i = 1; i <= m; i++) {
20
+ curr[0] = i;
21
+ for (let j = 1; j <= n; j++) {
22
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
23
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
24
+ }
25
+ [prev, curr] = [curr, prev];
26
+ }
27
+ return prev[n];
28
+ }
29
+
30
+ /**
31
+ * Nearest candidate name by edit distance, or `undefined` when nothing is
32
+ * plausibly close. "Close" means distance ≤ `maxDistance` (default 2) OR
33
+ * ≤ 40% of the input length — the shared threshold both call sites use.
34
+ *
35
+ * @param options.map - Optional post-processor applied to the winning
36
+ * candidate (e.g. `resolveTemplateName` to canonicalize an alias hit).
37
+ * @param options.maxDistance - Absolute distance floor for "close" (default 2).
38
+ */
39
+ export function nearestName(
40
+ input: string,
41
+ candidates: Iterable<string>,
42
+ options?: { map?: (best: string) => string; maxDistance?: number },
43
+ ): string | undefined {
44
+ const maxDistance = options?.maxDistance ?? 2;
45
+ let best: string | undefined;
46
+ let bestDist = Infinity;
47
+ const lower = input.toLowerCase();
48
+ for (const candidate of candidates) {
49
+ const dist = levenshtein(lower, candidate.toLowerCase());
50
+ if (dist < bestDist) {
51
+ bestDist = dist;
52
+ best = candidate;
53
+ }
54
+ }
55
+ if (best && (bestDist <= maxDistance || bestDist <= Math.ceil(input.length * 0.4))) {
56
+ return options?.map ? options.map(best) : best;
57
+ }
58
+ return undefined;
59
+ }