@vanillaskyai/video 0.1.0

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 (162) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/LICENSE +201 -0
  3. package/PUBLIC-API.md +276 -0
  4. package/README.md +152 -0
  5. package/SECURITY.md +22 -0
  6. package/SUPPORT.md +28 -0
  7. package/bin/vanillasky.js +3 -0
  8. package/dist/bg-confetti-WNQXS7ZS.js +89 -0
  9. package/dist/bg-emoji-ETI47GLS.js +150 -0
  10. package/dist/bg-media-SVUZAMGX.js +99 -0
  11. package/dist/brand-message-F3RD4P3P.js +253 -0
  12. package/dist/builtin-server-F7YXRVCH.js +8 -0
  13. package/dist/catalog-types-BIhSpOWK.d.ts +74 -0
  14. package/dist/chart-bar-CNSC7BNK.js +304 -0
  15. package/dist/chart-counter-MCRHKN77.js +299 -0
  16. package/dist/chart-progress-ring-RMI4A7ZK.js +296 -0
  17. package/dist/check-runtime.d.ts +15 -0
  18. package/dist/check-runtime.js +102 -0
  19. package/dist/chunk-2E6T633S.js +27 -0
  20. package/dist/chunk-352ZZCET.js +83 -0
  21. package/dist/chunk-73NTSFFI.js +81 -0
  22. package/dist/chunk-A2X45FET.js +46 -0
  23. package/dist/chunk-ABPKM4GK.js +187 -0
  24. package/dist/chunk-ABZACD4G.js +216 -0
  25. package/dist/chunk-BCRUUJ6A.js +756 -0
  26. package/dist/chunk-CHLGW22F.js +3551 -0
  27. package/dist/chunk-CJ7F2CE6.js +104 -0
  28. package/dist/chunk-CR7FE7BW.js +109 -0
  29. package/dist/chunk-E7CL7UPB.js +109 -0
  30. package/dist/chunk-EE6PK6XC.js +18 -0
  31. package/dist/chunk-ERY4S6QV.js +385 -0
  32. package/dist/chunk-EVG2ZYIY.js +125 -0
  33. package/dist/chunk-FNAEQ7QO.js +155 -0
  34. package/dist/chunk-FQ47WXAJ.js +38 -0
  35. package/dist/chunk-FSG2PYWG.js +336 -0
  36. package/dist/chunk-GHJEAP4O.js +504 -0
  37. package/dist/chunk-GRMUV7QY.js +262 -0
  38. package/dist/chunk-IFOW65Z6.js +205 -0
  39. package/dist/chunk-IXCUY3B3.js +786 -0
  40. package/dist/chunk-L5BTTCZV.js +97 -0
  41. package/dist/chunk-LMG3GHLL.js +107 -0
  42. package/dist/chunk-OOPIETLW.js +101 -0
  43. package/dist/chunk-P22HHFDK.js +43 -0
  44. package/dist/chunk-QAAO7OAY.js +19 -0
  45. package/dist/chunk-S7HFLYNN.js +35 -0
  46. package/dist/chunk-SPVTJH3F.js +24 -0
  47. package/dist/chunk-W2XJKG6C.js +3177 -0
  48. package/dist/chunk-XB7LH4J2.js +301 -0
  49. package/dist/chunk-XGRA2MUV.js +242 -0
  50. package/dist/chunk-XTOXFMLM.js +105 -0
  51. package/dist/chunk-YC5MFXA5.js +132 -0
  52. package/dist/cli.d.ts +1 -0
  53. package/dist/cli.js +1681 -0
  54. package/dist/compose-video-CN3VSWST.js +13 -0
  55. package/dist/cta-logo-SVA5GP2A.js +209 -0
  56. package/dist/cta-media-E7VLLK42.js +215 -0
  57. package/dist/events-_wfjbmEp.d.ts +81 -0
  58. package/dist/incoming-call-62MFZL3Z.js +320 -0
  59. package/dist/index.d.ts +15 -0
  60. package/dist/index.js +14 -0
  61. package/dist/infographic-before-after-AXCDAWND.js +460 -0
  62. package/dist/infographic-feature-list-GW5GUE6O.js +266 -0
  63. package/dist/infographic-problem-solution-7NURJANA.js +340 -0
  64. package/dist/infographic-stat-row-J7IZR6ZN.js +241 -0
  65. package/dist/infographic-steps-VSZM6F5M.js +400 -0
  66. package/dist/kit-BMNcHqa9.d.ts +79 -0
  67. package/dist/prompt-input-LOUCRXCY.js +236 -0
  68. package/dist/react.d.ts +81 -0
  69. package/dist/react.js +835 -0
  70. package/dist/reaction-QG4CYZLQ.js +119 -0
  71. package/dist/server.d.ts +141 -0
  72. package/dist/server.js +498 -0
  73. package/dist/showcase-code-RUTFZR3H.js +351 -0
  74. package/dist/showcase-phone-6CP6XEU5.js +272 -0
  75. package/dist/showcase-terminal-MGAEQWPR.js +306 -0
  76. package/dist/showcase-web-Y4W2HVRJ.js +390 -0
  77. package/dist/social-conversation-75SD2637.js +695 -0
  78. package/dist/social-milestone-XI2QE5BO.js +292 -0
  79. package/dist/social-notification-2UOHQCUY.js +244 -0
  80. package/dist/social-review-stack-FDYZGZTJ.js +239 -0
  81. package/dist/social-testimonial-OQ7N52GJ.js +284 -0
  82. package/dist/social-tweet-5OLCNPPC.js +295 -0
  83. package/dist/state-DZcKuS32.d.ts +3 -0
  84. package/dist/system-prompt-A6UXNTPK.js +8 -0
  85. package/dist/template-catalog.d.ts +24 -0
  86. package/dist/template-catalog.js +6 -0
  87. package/dist/templates.d.ts +25 -0
  88. package/dist/templates.js +19 -0
  89. package/dist/test.d.ts +149 -0
  90. package/dist/test.js +272 -0
  91. package/dist/text-stream-6XHC7J47.js +10 -0
  92. package/dist/types-B-PZR6G_.d.ts +208 -0
  93. package/dist/validate-T7GBU2YF.js +8 -0
  94. package/docs/agent-integration.md +39 -0
  95. package/docs/architecture.md +84 -0
  96. package/docs/branding-and-personalization.md +87 -0
  97. package/docs/concepts.md +106 -0
  98. package/docs/custom-templates.md +329 -0
  99. package/docs/customization.md +141 -0
  100. package/docs/errors.md +70 -0
  101. package/docs/getting-started.md +111 -0
  102. package/docs/input-and-first-scene.md +41 -0
  103. package/docs/integrate-nextjs.md +79 -0
  104. package/docs/media-and-audio.md +69 -0
  105. package/docs/motion-and-effects.md +107 -0
  106. package/docs/persistence.md +95 -0
  107. package/docs/production.md +122 -0
  108. package/docs/prompt-and-input.md +185 -0
  109. package/docs/provider-integration.md +115 -0
  110. package/docs/reference/protocol.md +96 -0
  111. package/docs/reference/provider-adapters.md +158 -0
  112. package/docs/responsive-orientation.md +37 -0
  113. package/docs/security.md +38 -0
  114. package/docs/streaming-protocol.md +15 -0
  115. package/docs/testing.md +137 -0
  116. package/docs/use-cases.md +59 -0
  117. package/examples/custom-template/README.md +19 -0
  118. package/examples/custom-template/minimal-text.tsx +82 -0
  119. package/examples/custom-template/structured-data.tsx +104 -0
  120. package/examples/custom-template/supplied-media.tsx +92 -0
  121. package/examples/nextjs-quickstart/.env.example +2 -0
  122. package/examples/nextjs-quickstart/README.md +22 -0
  123. package/examples/nextjs-quickstart/next-env.d.ts +4 -0
  124. package/examples/nextjs-quickstart/package.json +24 -0
  125. package/examples/nextjs-quickstart/src/app/api/video/route.ts +22 -0
  126. package/examples/nextjs-quickstart/src/app/layout.tsx +5 -0
  127. package/examples/nextjs-quickstart/src/app/page.tsx +31 -0
  128. package/examples/nextjs-quickstart/tsconfig.json +26 -0
  129. package/package.json +167 -0
  130. package/registry/items/backgrounds.json +52 -0
  131. package/registry/items/barChart.json +237 -0
  132. package/registry/items/beforeAfter.json +180 -0
  133. package/registry/items/bigNumber.json +225 -0
  134. package/registry/items/brandMessage.json +158 -0
  135. package/registry/items/cardList.json +206 -0
  136. package/registry/items/chatMessenger.json +151 -0
  137. package/registry/items/chatWhatsapp.json +151 -0
  138. package/registry/items/codeEditor.json +184 -0
  139. package/registry/items/confetti.json +107 -0
  140. package/registry/items/ctaLogo.json +160 -0
  141. package/registry/items/ctaMedia.json +193 -0
  142. package/registry/items/emojiBurst.json +101 -0
  143. package/registry/items/incomingCall.json +170 -0
  144. package/registry/items/media.json +179 -0
  145. package/registry/items/milestone.json +205 -0
  146. package/registry/items/motion.json +45 -0
  147. package/registry/items/notification.json +182 -0
  148. package/registry/items/phoneMockup.json +254 -0
  149. package/registry/items/problemSolution.json +186 -0
  150. package/registry/items/progressRing.json +199 -0
  151. package/registry/items/promptInput.json +148 -0
  152. package/registry/items/reaction.json +170 -0
  153. package/registry/items/reviewStack.json +224 -0
  154. package/registry/items/steps.json +207 -0
  155. package/registry/items/template-context.json +31 -0
  156. package/registry/items/terminal.json +212 -0
  157. package/registry/items/testimonial.json +164 -0
  158. package/registry/items/theme.json +47 -0
  159. package/registry/items/tripleStats.json +228 -0
  160. package/registry/items/tweet.json +203 -0
  161. package/registry/items/typography.json +47 -0
  162. package/registry/items/webMockup.json +273 -0
@@ -0,0 +1,225 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "bigNumber",
4
+ "type": "registry:block",
5
+ "title": "Big number",
6
+ "description": "A single animated count-up metric with headline and label.",
7
+ "dependencies": [
8
+ "react"
9
+ ],
10
+ "registryDependencies": [
11
+ "@vanillasky/backgrounds",
12
+ "@vanillasky/motion",
13
+ "@vanillasky/theme",
14
+ "@vanillasky/typography",
15
+ "@vanillasky/template-context"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "src/visual-system/scene-templates/chart-counter.tsx",
20
+ "type": "registry:component",
21
+ "target": "vanillasky/scene-templates/chart-counter.tsx",
22
+ "content": "/**\n * chart-counter — animated counting number with label + text overlay.\n *\n * Big number counts up from 0 to target value driven by scene progress.\n * Optional prefix/suffix, label below, accent underline, beat-reactive pulse.\n * TextOverlay in the top 30% carries a message above the counter.\n */\nimport type { SceneTemplateProps } from \"./types\";\nimport { resolveTokens } from \"../theme\";\nimport { TemplateText } from \"./template-text\";\nimport { withOpacity } from \"../theme\";\nimport { TOP_TEXT_AREA_RATIO } from \"../backgrounds\";\nimport { SceneBackground, getMediaBackgroundProps } from \"./scene-background\";\nimport { ConfettiLayer } from \"./confetti-layer\";\nimport { getResponsiveFontSize } from \"../typography\";\nimport { CountUpNumber } from \"../primitives/typography/CountUpNumber\";\n\nexport const ChartCounterTemplate: React.FC<SceneTemplateProps> = ({\n variables,\n style,\n progress,\n motionProgress = progress,\n beatIntensity,\n width,\n height,\n safeZone,\n sceneDuration,\n isPlaying = true,\n backgroundEffect,\n}) => {\n const s = Math.min(width, height) / 1080;\n const { primary, foreground, font, background } = resolveTokens(style);\n const chartColor = primary;\n const textColor = foreground;\n const textsRaw = String(variables.texts || \"\");\n const gradSeed = textsRaw.split(\"\").reduce((acc, c) => acc + c.charCodeAt(0), 0);\n\n const parsedValue = Number(variables.value);\n const targetValue = Number.isFinite(parsedValue) ? parsedValue : 1000;\n const parsedDecimalPlaces = Number(variables.decimalPlaces);\n const decimalPlaces = Number.isFinite(parsedDecimalPlaces)\n ? Math.max(0, Math.min(6, Math.trunc(parsedDecimalPlaces)))\n : undefined;\n const label = String(variables.label || \"Total users\");\n const prefix = String(variables.prefix || \"\");\n const unit = String(variables.unit || \"\");\n\n // Media-mode legibility: when a Pexels photo/video is behind, force the\n // label to white at full opacity (not the 60% brand-text dim that works\n // on a flat gradient) and stack a stronger drop-shadow on number + label\n // so they punch through busy footage. Mirrors how bg-media handles text\n // contrast over photos. SceneBackground already adds a vignette + bottom\n // scrim — these are the per-text reinforcements.\n const hasMedia = !!String(variables.mediaUrl || \"\").trim() &&\n String(variables.mediaType || \"auto\") !== \"gradient\";\n const numberColor = hasMedia ? \"#ffffff\" : textColor;\n const labelColor = hasMedia ? \"#ffffff\" : withOpacity(textColor, 0.6);\n // Confetti burst — fires when the count-up completes. Reuses the shared\n // ConfettiLayer (the 200-particle falling-confetti physics also used by\n // bg-confetti and bg-media's `confetti: true`) instead of an inline\n // bespoke burst, so the celebration feel is consistent across templates.\n // The layer's progress runs 0→1 internally; we remap scene [0.55, 1.0]\n // onto that so particles burst on count completion and fall through the\n // held state.\n const confettiActive = variables.confetti === true && motionProgress >= 0.55;\n const confettiProgress = confettiActive ? (motionProgress - 0.55) / 0.45 : 0;\n\n const labelFontSize = getResponsiveFontSize(label, width, \"subtitle\", height) * 1.3;\n\n return (\n <div\n style={{\n width,\n height,\n backgroundColor: \"#000\",\n position: \"relative\",\n overflow: \"hidden\",\n fontFamily: font,\n }}\n >\n {/* [slot: background] Gradient by default, optionally a Pexels photo/video when mediaUrl is set */}\n <SceneBackground\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n width={width}\n height={height}\n {...getMediaBackgroundProps(variables)}\n backgroundEffect={backgroundEffect}\n seed={gradSeed}\n isPlaying={isPlaying}\n beatIntensity={beatIntensity}\n />\n\n {/* [slot: background] Subtle radial glow behind number — accent atmospheric */}\n <div\n style={{\n position: \"absolute\",\n top: \"50%\",\n left: \"50%\",\n transform: \"translate(-50%, -55%)\",\n width: 500 * s,\n height: 500 * s,\n borderRadius: \"50%\",\n background: `radial-gradient(circle, ${withOpacity(primary, 0.15)} 0%, transparent 70%)`,\n pointerEvents: \"none\",\n }}\n />\n\n {/* [slot: badge] Confetti — shared 200-particle layer, fires when count completes */}\n {confettiActive && (\n <ConfettiLayer\n progress={confettiProgress}\n width={width}\n height={height}\n beatIntensity={beatIntensity}\n bgColor={hasMedia ? undefined : background.type === \"solid\" ? background.color : background.colors[1]}\n />\n )}\n\n {/* [slot: caption] Headline — top text area (TemplateText) */}\n <div style={{ position: \"absolute\", top: 0, left: 0, right: 0, height: height * TOP_TEXT_AREA_RATIO, overflow: \"visible\" }}>\n <TemplateText\n motionProgress={motionProgress}\n typeTreatment={resolveTokens(style).preset.type}\n archetype=\"subtle\"\n text={String(variables.texts ?? \"\")}\n progress={progress}\n sceneDuration={sceneDuration ?? 3}\n width={width}\n height={height}\n position=\"top\"\n sizeRole=\"headline\"\n safeZone={safeZone}\n font={font}\n color={textColor}\n beatIntensity={beatIntensity}\n />\n </div>\n\n {/* [slot: hero] Counter — CountUpNumber primitive. The primitive\n provides its own positioned wrapper (the same one chart-counter used\n inline pre-refactor), so we render it bare here. */}\n <CountUpNumber\n progress={progress}\n motionProgress={motionProgress}\n target={targetValue}\n decimalPlaces={decimalPlaces}\n prefix={prefix}\n unit={unit}\n label={label}\n width={width}\n height={height}\n numberColor={numberColor}\n labelColor={labelColor}\n chartColor={chartColor}\n font={font}\n hasMediaShadow={hasMedia}\n beatIntensity={beatIntensity}\n labelFontSize={labelFontSize}\n />\n\n </div>\n );\n};\n"
23
+ },
24
+ {
25
+ "path": "src/visual-system/primitives/typography/CountUpNumber.tsx",
26
+ "type": "registry:component",
27
+ "target": "vanillasky/primitives/typography/CountUpNumber.tsx",
28
+ "content": "/**\n * CountUpNumber\n *\n * Renders the count-up number + label block exactly as chart-counter does.\n * chart-counter is refactored to import and use this component so both\n * paths render pixel-identical output.\n *\n * Two surfaces:\n * 1. `<CountUpNumber {...typedProps} />` — direct use from templates\n * 2. Registered primitive — used by custom scenes via composition JSON\n */\n\nimport { interpolate, spring } from \"../../motion\";\nimport { TOKEN_DEFAULTS } from \"../../theme\";\n\n// ─── Typed component (for direct use from templates) ────────────\n\nexport interface CountUpNumberProps {\n /** Scene progress 0→1 */\n progress: number;\n motionProgress?: number;\n /** Number to count up to */\n target: number;\n /** Optional source precision override, including meaningful trailing zeros. */\n decimalPlaces?: number;\n /** Optional prefix (e.g. \"$\", \"€\") — rendered in chartColor */\n prefix?: string;\n /** Optional unit suffix (e.g. \"%\", \"k\", \"M\") — rendered in chartColor */\n unit?: string;\n /** Label rendered below the number — fades in 0.2→0.4 progress */\n label: string;\n /** Frame width */\n width: number;\n /** Frame height */\n height: number;\n /** Color for the number digits */\n numberColor?: string;\n /** Color for the label below */\n labelColor?: string;\n /** Color for prefix/unit accents — defaults to numberColor */\n chartColor?: string;\n /** Font family */\n font?: string;\n /** When true, applies stronger text-shadow for media backgrounds */\n hasMediaShadow?: boolean;\n /** Beat pulse 0→1 */\n beatIntensity?: number;\n /** Optional override for label font size (default: getResponsiveFontSize × 1.3) */\n labelFontSize?: number;\n}\n\nfunction suppliedDecimalPlaces(value: number): number {\n if (!Number.isFinite(value) || Number.isInteger(value)) return 0;\n const normalized = String(value).toLowerCase();\n if (normalized.includes(\"e-\")) {\n const [coefficient, exponentText] = normalized.split(\"e-\");\n const coefficientDecimals = coefficient.split(\".\")[1]?.length ?? 0;\n return Math.min(6, Number(exponentText) + coefficientDecimals);\n }\n return Math.min(6, normalized.split(\".\")[1]?.length ?? 0);\n}\n\n/**\n * The number + label block extracted from chart-counter.tsx lines 121-302.\n * Motion math is identical: same count-up curve, same spring config, same\n * breath equation, same beat scale, same label opacity ramp.\n */\nexport const CountUpNumber: React.FC<CountUpNumberProps> = ({\n progress,\n motionProgress = progress,\n target,\n decimalPlaces: decimalPlacesOverride,\n prefix = \"\",\n unit = \"\",\n label,\n width,\n height,\n numberColor = \"#ffffff\",\n labelColor = \"rgba(255,255,255,0.6)\",\n font = TOKEN_DEFAULTS.font,\n hasMediaShadow = false,\n beatIntensity = 0,\n labelFontSize,\n}) => {\n const s = Math.min(width, height) / 1080;\n\n // Count-up: interpolate from 0 to target over 0-0.55 progress, then hold.\n // (verbatim from chart-counter)\n const displayValue = interpolate(\n progress,\n [0, 0.55] as const,\n [0, target] as const,\n { extrapolateRight: \"clamp\" },\n );\n const decimalPlaces = Number.isFinite(decimalPlacesOverride)\n ? Math.max(0, Math.min(6, Math.trunc(decimalPlacesOverride ?? 0)))\n : suppliedDecimalPlaces(target);\n const formattedNumber = displayValue.toLocaleString(\"en-US\", {\n minimumFractionDigits: decimalPlaces,\n maximumFractionDigits: decimalPlaces,\n });\n const formattedTarget = target.toLocaleString(\"en-US\", {\n minimumFractionDigits: decimalPlaces,\n maximumFractionDigits: decimalPlaces,\n });\n const semanticValueIsTransient = formattedNumber !== formattedTarget;\n\n // Responsive font size for the number based on string length\n const fullNumberStr = `${prefix}${formattedNumber}${unit}`;\n const baseSize = 140 * s;\n const charCount = fullNumberStr.length;\n const numberFontSize = charCount <= 6 ? baseSize : baseSize * Math.max(0.35, 6 / charCount);\n\n // Number entrance — bouncy spring scale-pop\n const numberSpring = spring(\n interpolate(motionProgress, [0, 0.22] as const, [0, 1] as const, {\n extrapolateLeft: \"clamp\",\n extrapolateRight: \"clamp\",\n }),\n { damping: 9, stiffness: 200 },\n );\n const numberScale = interpolate(numberSpring, [0, 1] as const, [0.3, 1] as const);\n\n // Subtle breathing once settled\n const numberSettled = numberSpring > 0.99;\n const breathe = numberSettled ? 1 + Math.sin(progress * Math.PI * 4) * 0.008 : 1;\n\n // Beat pulse on number\n const beatScale = 1 + beatIntensity * 0.03;\n\n // Label fade-in: progress 0.2 -> 0.4\n const labelOpacity = interpolate(\n motionProgress,\n [0.2, 0.4] as const,\n [0, 1] as const,\n { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\" },\n );\n\n const mediaTextShadow = hasMediaShadow\n ? \"0 2px 8px rgba(0,0,0,0.55), 0 6px 16px rgba(0,0,0,0.35)\"\n : undefined;\n\n const finalLabelFontSize = labelFontSize ?? 36 * s * 1.3;\n\n // Self-contained wrapper — matches chart-counter's counter-block wrapper\n // byte-for-byte. The primitive renders this when consumed by chart-counter\n // (replacing the inline wrapper) AND when rendered by composed.tsx for\n // composition JSON — both produce identical DOM.\n return (\n <div\n style={{\n position: \"absolute\",\n top: 0,\n left: 0,\n right: 0,\n bottom: 0,\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"center\",\n justifyContent: \"center\",\n pointerEvents: \"none\",\n // font is intentionally NOT set here — caller's outer wrapper\n // (chart-counter root div or composed.tsx root) sets fontFamily\n // and the children inherit. void to silence unused-arg lint.\n ...((void font, {}) as React.CSSProperties),\n }}\n >\n {/* Number */}\n <div\n data-transition-semantic={semanticValueIsTransient ? \"transient\" : undefined}\n style={{\n fontSize: numberFontSize,\n fontWeight: 800,\n color: numberColor,\n textAlign: \"center\",\n lineHeight: 1.1,\n position: \"relative\",\n zIndex: 1,\n maxWidth: \"90%\",\n overflow: \"visible\",\n ...(semanticValueIsTransient\n ? { visibility: \"var(--vanillasky-transition-semantic-visibility,visible)\" as React.CSSProperties[\"visibility\"] }\n : {}),\n transform: `scale(${numberScale * beatScale * breathe})`,\n ...(mediaTextShadow ? { textShadow: mediaTextShadow } : {}),\n }}\n >\n {prefix}\n {formattedNumber}\n {unit}\n </div>\n\n {/* Label */}\n <div\n style={{\n opacity: labelOpacity,\n fontSize: finalLabelFontSize,\n color: labelColor,\n textTransform: \"uppercase\",\n letterSpacing: 2 * s,\n textAlign: \"center\",\n marginTop: 12 * s,\n position: \"relative\",\n zIndex: 1,\n ...(mediaTextShadow ? { textShadow: mediaTextShadow } : {}),\n }}\n >\n {label}\n </div>\n </div>\n );\n};\n"
29
+ },
30
+ {
31
+ "path": "src/visual-system/scene-templates/confetti-layer.tsx",
32
+ "type": "registry:component",
33
+ "target": "vanillasky/scene-templates/confetti-layer.tsx",
34
+ "content": "/**\n * confetti-layer — reusable confetti burst layer.\n *\n * Extracted from bg-confetti.tsx so any host template can compose the same\n * physics on top of its own backdrop. Used by:\n * - bg-confetti.tsx (the standalone celebration template — gradient bg)\n * - bg-media.tsx (conditional via the `confetti` boolean — confetti\n * layered over photo / video / gradient)\n *\n * Physics: 200 particles, even angular fan-out (360°) from center.\n * Particles move RADIALLY outward — both x and y follow the burst angle so\n * each particle exits the frame in its launch direction (top, sides, bottom\n * corners). A very gentle gravity tugs them slightly downward over time so\n * the motion still reads as natural, but it's never strong enough to reverse\n * an upward trajectory before the particle exits the frame. 3D tumble\n * approximated via scaleX flip. Progress-driven — deterministic for export.\n */\n\nimport React from \"react\";\nimport { getCelebrationParticleTiming } from \"./celebration-particle-timing\";\n\nconst PARTICLE_COUNT = 200;\n\n// Confetti palette spans the full hue wheel; filtered at runtime to drop any\n// color too close in hue to the background (and would disappear).\nconst COLORS = [\"#ef4444\", \"#ff6b6b\", \"#f97316\", \"#fb923c\", \"#ffd93d\", \"#34d399\", \"#6bcbff\", \"#818cf8\", \"#a78bfa\", \"#f472b6\", \"#ffffff\"];\n\nfunction seededRandom(seed: number): number {\n const x = Math.sin(seed * 12.9898 + seed * 78.233) * 43758.5453;\n return x - Math.floor(x);\n}\n\n/** Outward sweep — softer ease-out quad. Less front-loaded than cubic so\n * the burst reads as a graceful arc rather than an explosion-and-stop. */\nfunction easeOutQuad(p: number): number {\n return 1 - (1 - p) * (1 - p);\n}\n\nfunction hexHue(hex: string): number {\n const r = parseInt(hex.slice(1, 3), 16) / 255;\n const g = parseInt(hex.slice(3, 5), 16) / 255;\n const b = parseInt(hex.slice(5, 7), 16) / 255;\n const max = Math.max(r, g, b);\n const min = Math.min(r, g, b);\n const d = max - min;\n if (d === 0) return 0;\n let h = 0;\n if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;\n else if (max === g) h = ((b - r) / d + 2) / 6;\n else h = ((r - g) / d + 4) / 6;\n return h * 360;\n}\n\nfunction hueDistance(a: number, b: number): number {\n const d = Math.abs(a - b) % 360;\n return d > 180 ? 360 - d : d;\n}\n\nexport interface ConfettiLayerProps {\n progress: number;\n width: number;\n height: number;\n beatIntensity?: number;\n /**\n * Optional background tone for hue-filtering. When provided, palette\n * colors within 35° of this hue are dropped so confetti doesn't blend\n * into a same-hue gradient. Pass the bg's dominant color (typically\n * secondary or accent). Omit for media-bg cases (photos/videos have no\n * single dominant color); the full palette will be used.\n */\n bgColor?: string;\n}\n\nexport const ConfettiLayer: React.FC<ConfettiLayerProps> = ({\n progress,\n width,\n height,\n beatIntensity = 0,\n bgColor,\n}) => {\n const s = Math.min(width, height) / 1080;\n /* Radial burst — each particle flies out in its launch direction until\n * it leaves the frame. Reach is sized off the LONG edge so even\n * particles aimed along the long axis (top/bottom in portrait, sides\n * in landscape) clear the frame edge by progress=1. Half-diagonal of\n * a 9:16 frame ≈ 0.61 * longEdge, so 0.95 gives ~35% headroom past\n * the corner — particles exit cleanly with a few hundred px of trail. */\n const BURST_RADIUS = Math.max(width, height) * 0.95;\n\n // Filter palette only if a bgColor is provided (gradient context). Over\n // photos/videos we keep the full palette since there's no single bg hue.\n let allColors = COLORS;\n if (bgColor) {\n const bgHue = hexHue(bgColor);\n const filtered = COLORS.filter(c => c === \"#ffffff\" || hueDistance(hexHue(c), bgHue) > 35);\n if (filtered.length >= 4) allColors = filtered;\n }\n\n return (\n <>\n {Array.from({ length: PARTICLE_COUNT }, (_, i) => {\n const seed = i + 1;\n\n // Even angular distribution — single biggest \"feel\" win vs random.\n const angle = (i / PARTICLE_COUNT) * Math.PI * 2;\n\n // Per-particle variance\n const forceFactor = 0.75 + seededRandom(seed * 1) * 0.5; // 0.75..1.25\n const durationFactor = 1.1 + seededRandom(seed * 3) * 0.6; // 1.1..1.7 — slow arc\n const color = allColors[i % allColors.length];\n const isCircle = seededRandom(seed * 4) > 0.85;\n const startRot = seededRandom(seed * 5) * 360;\n const rotSpeed = (seededRandom(seed * 6) - 0.5) * 1440;\n const flipSpeed = 3 + seededRandom(seed * 7) * 4;\n const flipPhase = seededRandom(seed * 8) * Math.PI * 2;\n const widthFactor = 0.45 + seededRandom(seed * 9) * 0.6;\n const heightFactor = 0.7 + seededRandom(seed * 10) * 0.6;\n\n const timing = getCelebrationParticleTiming(progress, i, durationFactor);\n if (!timing) return null;\n const p = timing.progress;\n\n // Radial burst — particles fly outward in their launch direction.\n // Outward distance eases out so they decelerate slightly near the\n // frame edge but don't reverse. A small quadratic gravity term tugs\n // them downward over time without overriding upward velocity before\n // they exit the frame.\n const reach = BURST_RADIUS * forceFactor;\n const radialP = easeOutQuad(p);\n const gravity = height * 0.08 * p * p;\n const x = width * 0.5 + Math.cos(angle) * reach * radialP;\n const y = height * 0.5 + Math.sin(angle) * reach * radialP + gravity;\n\n const flip = Math.cos(p * Math.PI * flipSpeed + flipPhase);\n const rot = startRot + rotSpeed * p;\n\n const baseSize = (8 + seededRandom(seed * 11) * 10) * s;\n const w = baseSize * widthFactor;\n const h = isCircle ? w : baseSize * heightFactor;\n\n const popScale = Math.min(1, p * 30);\n const particleOpacity = p < 0.85 ? 1 : Math.max(0, 1 - (p - 0.85) / 0.15);\n const opacity = particleOpacity * timing.opacity;\n const beatPulse = 1 + beatIntensity * 0.15;\n\n if (opacity <= 0) return null;\n if (y > height + 120 || y < -120 || x < -120 || x > width + 120) return null;\n\n return (\n <div\n key={i}\n style={{\n position: \"absolute\",\n left: x - w / 2,\n top: y - h / 2,\n width: w,\n height: h,\n borderRadius: isCircle ? \"50%\" : 1,\n backgroundColor: color,\n opacity,\n transform: `rotate(${rot}deg) scaleX(${flip}) scale(${popScale * beatPulse})`,\n pointerEvents: \"none\",\n }}\n />\n );\n })}\n </>\n );\n};\n"
35
+ },
36
+ {
37
+ "path": "src/visual-system/scene-templates/celebration-particle-timing.ts",
38
+ "type": "registry:lib",
39
+ "target": "vanillasky/scene-templates/celebration-particle-timing.ts",
40
+ "content": "const SECOND_WAVE_INTERVAL = 4;\nconst SECOND_WAVE_START = 0.33;\nconst SECOND_WAVE_DURATION = 1;\nconst EXIT_FADE_START = 0.76;\nconst EXIT_FADE_END = 0.85;\n\nexport interface CelebrationParticleTiming {\n progress: number;\n opacity: number;\n}\n\n/**\n * Keeps most particles on the opening burst while delaying a restrained\n * quarter-sized cohort into the back half of the scene. The final fade is\n * scene-relative so particles always clear before the copy exits, regardless\n * of their individual physics duration.\n */\nexport function getCelebrationParticleTiming(\n sceneProgress: number,\n particleIndex: number,\n durationFactor: number,\n): CelebrationParticleTiming | null {\n if (sceneProgress >= EXIT_FADE_END) return null;\n\n const isSecondWave = particleIndex % SECOND_WAVE_INTERVAL === 0;\n if (isSecondWave && sceneProgress <= SECOND_WAVE_START) return null;\n\n const waveProgress = isSecondWave\n ? (sceneProgress - SECOND_WAVE_START) / SECOND_WAVE_DURATION\n : sceneProgress;\n const progress = Math.min(1, Math.max(0, waveProgress / durationFactor));\n const opacity = sceneProgress <= EXIT_FADE_START\n ? 1\n : (EXIT_FADE_END - sceneProgress) / (EXIT_FADE_END - EXIT_FADE_START);\n\n return { progress, opacity };\n}\n"
41
+ },
42
+ {
43
+ "path": "src/visual-system/scene-templates/scene-background.tsx",
44
+ "type": "registry:component",
45
+ "target": "vanillasky/scene-templates/scene-background.tsx",
46
+ "content": "/**\n * SceneBackground — shared backdrop component for any scene template that\n * wants to support both a brand-color gradient and stock media (Pexels\n * photo / video) as an alternate atmosphere.\n *\n * Usage:\n * <SceneBackground\n * style={style}\n * progress={progress}\n * sceneDuration={sceneDuration}\n * width={width}\n * height={height}\n * mediaUrl={String(variables.mediaUrl || \"\")}\n * mediaType={String(variables.mediaType || \"auto\")}\n * seed={String(variables.texts || \"\")}\n * isPlaying={isPlaying}\n * />\n * ... template's content layered on top\n *\n * Behavior:\n * - Brand gradient is the always-on backdrop (uses BrandGradientOverlay).\n * - When mediaUrl is set and mediaType isn't \"gradient\", the photo/video\n * covers the gradient. Vignette + bottom-half darken give the content\n * contrast against busy footage.\n * - mediaType=\"gradient\" deliberately ignores mediaUrl and renders only\n * the brand gradient. First-class atmospheric mode.\n * - When mediaUrl is empty / 404s / Pexels search returned nothing,\n * gradient shows through cleanly (matches every other gradient-backed\n * template).\n *\n * Extracted from bg-media.tsx so any template can compose it. bg-media\n * now uses this component too — its \"media is the scene\" identity comes\n * from how it positions the title (centered, full-frame), not from\n * duplicated render logic.\n */\n\nimport React, { useEffect, useRef } from \"react\";\nimport type { TemplateStyle } from \"../template-context\";\nimport { BrandGradientOverlay } from \"../backgrounds\";\nimport { getBackgroundTransform } from \"../backgrounds\";\n\nconst VIDEO_EXTENSIONS = [\".mp4\", \".webm\", \".mov\", \".m4v\", \".avi\"];\n\nfunction isVideoUrl(url: string): boolean {\n try {\n const pathname = new URL(url).pathname.toLowerCase();\n return VIDEO_EXTENSIONS.some((ext) => pathname.endsWith(ext));\n } catch {\n const lower = url.toLowerCase();\n return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext));\n }\n}\n\nexport type ResolvedMediaType = \"photo\" | \"video\" | \"gradient\";\n\nexport type MediaPosition = \"center\" | \"top\" | \"bottom\" | \"left\" | \"right\";\nexport type MediaTreatment = \"subtle\" | \"cinematic\" | \"text-safe\";\n\nconst MEDIA_POSITIONS: Record<MediaPosition, string> = {\n center: \"center center\",\n top: \"center top\",\n bottom: \"center bottom\",\n left: \"left center\",\n right: \"right center\",\n};\n\nexport function resolveMediaPosition(value: string): string {\n return MEDIA_POSITIONS[value as MediaPosition] ?? MEDIA_POSITIONS.center;\n}\n\nexport function resolveMediaTreatment(value: string): MediaTreatment {\n return value === \"subtle\" || value === \"text-safe\" ? value : \"cinematic\";\n}\n\nexport interface MediaTreatmentLayer {\n id: \"vignette\" | \"full-wash\" | \"center-scrim\" | \"bottom-scrim\";\n background: string;\n style?: React.CSSProperties;\n}\n\n/** Export-safe contrast recipes. Overlays only: SVG capture cannot rely on CSS filters. */\nexport function getMediaTreatmentLayers(value: string): MediaTreatmentLayer[] {\n const treatment = resolveMediaTreatment(value);\n const vignette: MediaTreatmentLayer = {\n id: \"vignette\",\n background:\n treatment === \"subtle\"\n ? \"radial-gradient(ellipse at center, transparent 45%, rgba(0,0,0,0.28) 100%)\"\n : \"radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,0.55) 80%, rgba(0,0,0,0.75) 100%)\",\n };\n if (treatment === \"subtle\") return [vignette];\n\n const cinematic: MediaTreatmentLayer[] = [\n vignette,\n {\n id: \"center-scrim\",\n background:\n treatment === \"text-safe\"\n ? \"radial-gradient(ellipse 90% 56% at 50% 50%, rgba(0,0,0,0.36) 0%, rgba(0,0,0,0.18) 55%, transparent 84%)\"\n : \"radial-gradient(ellipse 85% 50% at 50% 50%, rgba(0,0,0,0.22) 0%, rgba(0,0,0,0.10) 50%, transparent 80%)\",\n },\n {\n id: \"bottom-scrim\",\n background:\n treatment === \"text-safe\"\n ? \"linear-gradient(to top, rgba(0,0,0,0.68) 0%, transparent 100%)\"\n : \"linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 100%)\",\n style: { top: \"55%\" },\n },\n ];\n\n if (treatment === \"text-safe\") {\n cinematic.splice(1, 0, {\n id: \"full-wash\",\n background: \"rgba(0,0,0,0.30)\",\n });\n }\n return cinematic;\n}\n\nexport function resolveMediaType(\n mediaType: string,\n mediaUrl: string,\n): ResolvedMediaType {\n if (mediaType === \"gradient\") return \"gradient\";\n if (mediaType === \"video\") return \"video\";\n if (mediaType === \"photo\") return \"photo\";\n // \"auto\" — detect from URL extension\n return mediaUrl && isVideoUrl(mediaUrl) ? \"video\" : \"photo\";\n}\n\nexport function getMediaBackgroundProps(variables: Record<string, unknown>) {\n return {\n mediaUrl: String(variables.mediaUrl || \"\"),\n mediaType: String(variables.mediaType || \"auto\"),\n mediaPoster: String(variables.mediaPoster || \"\"),\n mediaPosition: String(variables.mediaPosition || \"center\"),\n mediaTreatment: String(variables.mediaTreatment || \"cinematic\"),\n };\n}\n\nexport interface SceneBackgroundProps {\n style: TemplateStyle;\n progress: number;\n sceneDuration?: number;\n width: number;\n height: number;\n mediaUrl?: string;\n mediaType?: string;\n /** Still image URL shown while the <video> backdrop decodes its first\n * frame. Without it the element renders transparent during the\n * ~50–400ms decode window and the gradient flashes through. */\n mediaPoster?: string;\n /** Cover-crop focal anchor. Keeps the important edge/subject visible. */\n mediaPosition?: string;\n /** Overlay recipe: subtle, cinematic, or stronger text-safe contrast. */\n mediaTreatment?: string;\n /** Background motion effect (drift / pulse / Ken Burns). Applied to the photo/video. */\n backgroundEffect?: string;\n /** Stable seed for the gradient breathing animation. Pass the scene's\n * text content (or any stable string) — it's hashed deterministically. */\n seed?: number | string;\n /** Pause video when preview is paused. Defaults to true (export path). */\n isPlaying?: boolean;\n beatIntensity?: number;\n}\n\nexport const SceneBackground: React.FC<SceneBackgroundProps> = ({\n style,\n progress,\n sceneDuration,\n width: _width, // accepted for symmetry; not currently used in render\n height: _height,\n mediaUrl = \"\",\n mediaType = \"auto\",\n mediaPoster,\n mediaPosition = \"center\",\n mediaTreatment = \"cinematic\",\n backgroundEffect,\n seed,\n isPlaying = true,\n beatIntensity = 0,\n}) => {\n void _width;\n void _height;\n const resolved = resolveMediaType(mediaType, mediaUrl);\n const showMedia = resolved !== \"gradient\" && !!mediaUrl;\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const resolvedTreatment = resolveMediaTreatment(mediaTreatment);\n const treatmentLayers = getMediaTreatmentLayers(resolvedTreatment);\n\n const gradSeed =\n typeof seed === \"number\"\n ? seed\n : typeof seed === \"string\"\n ? seed.split(\"\").reduce((acc, c) => acc + c.charCodeAt(0), 0)\n : 0;\n\n const bgTransform = getBackgroundTransform(\n backgroundEffect,\n progress,\n beatIntensity,\n );\n\n // Video playback control — same pause/seek logic bg-media used pre-extract.\n const videoRef = useRef<HTMLVideoElement>(null);\n const lastProgress = useRef(progress);\n const videoStarted = useRef(false);\n\n useEffect(() => {\n const vid = videoRef.current;\n if (!vid) return;\n if (!isPlaying) {\n vid.pause();\n videoStarted.current = false;\n return;\n }\n const progressChanged = Math.abs(progress - lastProgress.current) > 0.001;\n lastProgress.current = progress;\n if (progressChanged && !videoStarted.current) {\n vid.playbackRate = 1;\n vid.currentTime = 0;\n vid.play().catch(() => {});\n videoStarted.current = true;\n } else if (!progressChanged && videoStarted.current) {\n vid.pause();\n videoStarted.current = false;\n }\n }, [progress, isPlaying]);\n\n // Release the decoder on unmount. Without this, iOS Safari keeps the\n // video's decoder buffer alive after the React node is gone — each\n // scene transition (or play/pause/play cycle that remounts the active\n // scene) leaks one decoder, eventually crossing the renderer's memory\n // ceiling and triggering \"A problem repeatedly occurred.\" Same recipe\n // as #409's CanvasPreview preload cleanup: pause → clear src → load().\n // Capture the ref at mount-time so the cleanup uses the same node we\n // mounted (the ref's .current is stale by unmount).\n useEffect(() => {\n const vid = videoRef.current;\n return () => {\n if (!vid) return;\n vid.pause();\n vid.removeAttribute(\"src\");\n vid.load();\n };\n }, []);\n\n return (\n <>\n <BrandGradientOverlay\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n seed={gradSeed}\n />\n\n {showMedia &&\n (resolved === \"video\" ? (\n <video\n ref={videoRef}\n src={mediaUrl}\n // Poster paints during the decode window so the user sees the\n // (still) first frame instead of a transparent <video> letting\n // the brand gradient show through. Pexels returns a thumbnail\n // image alongside each video; fillPexelsUrls stores it in\n // `variables.mediaPoster`. Layered defense alongside preload=\"auto\"\n // below: on desktop the byte preloader makes decode fast, on\n // mobile (where the preloader skips video pre-mounting to dodge\n // the iOS Safari memory crash) the poster is the primary shield.\n poster={mediaPoster || undefined}\n muted\n loop\n playsInline\n // preload=\"auto\" — without it, browsers default to \"metadata\":\n // they only load the container/dimensions, not the byte stream\n // needed to decode frames. The element then renders transparent\n // until the first decoded frame arrives, letting the brand\n // gradient flash through whenever a scene mid-playback transitions\n // to a media backdrop. The parent preloader caches the bytes, but\n // decoder state is per-element, so the active mount still has to\n // decode the first frame; \"auto\" kicks that work off the instant\n // the element mounts.\n preload=\"auto\"\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: resolvedPosition,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n }}\n />\n ) : (\n <div\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n backgroundImage: `url(${mediaUrl})`,\n backgroundSize: \"cover\",\n backgroundPosition: resolvedPosition,\n }}\n />\n ))}\n\n {showMedia &&\n treatmentLayers.map((layer) => (\n <div\n key={layer.id}\n data-media-treatment={resolvedTreatment}\n data-media-overlay={layer.id}\n style={{\n position: \"absolute\",\n inset: 0,\n background: layer.background,\n pointerEvents: \"none\",\n ...layer.style,\n }}\n />\n ))}\n </>\n );\n};\n"
47
+ },
48
+ {
49
+ "path": "src/visual-system/scene-templates/template-text.tsx",
50
+ "type": "registry:component",
51
+ "target": "vanillasky/scene-templates/template-text.tsx",
52
+ "content": "/**\n * TemplateText — unified text component for scene templates.\n *\n * Replaces the per-template hand-rolled text rendering with a single component\n * that owns: archetype motion lifecycle (entrance + hold + exit), font sizing,\n * position, beat pulse, and safe zone.\n *\n * Each template declares its constraints (position + sizeRole) at the call site;\n * the user/AI picks the archetype. Templates that can only show text at the top\n * just always pass position=\"top\".\n *\n * Example — a data template (caption above a chart):\n *\n * <TemplateText\n * archetype={textArchetype}\n * text={variables.title}\n * progress={progress}\n * sceneDuration={sceneDuration}\n * width={width}\n * height={height}\n * position=\"top\"\n * sizeRole=\"caption\"\n * />\n *\n * Example — a media template (full-frame headline):\n *\n * <TemplateText\n * archetype={textArchetype}\n * text={variables.headline}\n * progress={progress}\n * sceneDuration={sceneDuration}\n * width={width}\n * height={height}\n * position=\"center\"\n * sizeRole=\"headline\"\n * beatIntensity={beatIntensity}\n * />\n *\n * Note: `textArchetype` is destructured from props (a scene-level\n * field on `SceneTemplateProps`), NOT read from `variables`. Copying\n * the wrong pattern silently no-ops — the executor routes\n * `setSceneVariable(\"textArchetype\", ...)` to the scene-level field,\n * never into variables, so `variables.textArchetype` is always\n * undefined.\n */\n\nimport {\n renderArchetype,\n normalizeArchetype,\n type TextArchetype,\n type ArchetypeRender,\n} from \"../typography\";\nimport type { TypeTreatment } from \"../theme\";\nimport { renderWithEmoji, planTypewriterEmoji } from \"../emoji/emoji-text\";\nimport { Emoji } from \"../emoji\";\n\nexport type TextPosition = \"top\" | \"center\" | \"bottom\";\nexport type TextSizeRole = \"headline\" | \"caption\" | \"label\";\n\nexport interface SafeZone {\n top: number;\n right: number;\n bottom: number;\n left: number;\n}\n\nexport interface TemplateTextProps {\n archetype: TextArchetype;\n text: string;\n /** Scene progress 0→1. */\n progress: number;\n /** Presentation clock; VideoFrame keeps it on the complete scene timeline. */\n motionProgress?: number;\n /** Scene duration in seconds — drives entrance/exit phase scaling. */\n sceneDuration: number;\n /** Frame width in pixels (1080 in production, smaller in previews). */\n width: number;\n /** Frame height in pixels (1920 in production). */\n height: number;\n /** Where the text box sits in the frame. Templates declare this. */\n position?: TextPosition;\n /** Size envelope. Templates declare this. */\n sizeRole?: TextSizeRole;\n /** Preset type treatment — weight/tracking/size/case shift from style.preset. */\n typeTreatment?: TypeTreatment;\n /** Padding from frame edges. Defaults to a 24px box. */\n safeZone?: SafeZone;\n /** Font family. */\n font?: string;\n /** Fill color. */\n color?: string;\n /** Beat intensity 0→1 (currently unused — kept for forward compat). */\n beatIntensity?: number;\n}\n\nconst DEFAULT_SAFE_ZONE: SafeZone = { top: 24, right: 24, bottom: 24, left: 24 };\nconst DEFAULT_FONT =\n \"ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif\";\n\n// ─── Typography constants ───────────────────────────────────────\n// All em-based so they scale with font size and behave consistently across\n// font families. Values from print/motion-design conventions:\n//\n// - Big text (display/headline) gets TIGHTER tracking and TIGHTER leading.\n// -0.022em (≈ -2.2%) is the sweet spot for 48–88px headlines on most\n// sans-serifs (Inter, Helvetica, SF Pro, Manrope, Geist).\n// - Word-spacing kept subtle (≤0.08em). CSS word-spacing is ADDITIVE on\n// top of the natural space char, so what looks like \"a touch of\n// rhythm\" in print becomes a visible double-gap on 80px motion\n// headlines (especially under wordStagger, where each word renders\n// as an inline-block and the gap between them is preserved). Old\n// values (0.16/0.18/0.22 em) added ~13–18px per gap on display\n// type — the \"too much space between words\" symptom.\n// - Line-height 1.1 for headlines, 1.5 for body — Bringhurst-aligned ratios.\n// - kern + liga always on so any font's pair-kerning and ligatures fire\n// consistently (works across Inter, Manrope, SF, IBM Plex, etc.).\nconst TYPO = {\n headline: {\n letterSpacing: \"-0.022em\",\n wordSpacing: \"0.04em\",\n lineHeight: 1.1,\n },\n caption: {\n letterSpacing: \"-0.012em\",\n wordSpacing: \"0.06em\",\n lineHeight: 1.25,\n },\n label: {\n letterSpacing: \"-0.005em\",\n wordSpacing: \"0.10em\",\n lineHeight: 1.4,\n },\n};\nconst FONT_FEATURES = '\"kern\" 1, \"liga\" 1';\n\n// Drop shadow tuned to give crisp edges on retina without muddying text on\n// saturated gradients. Earlier two-layer shadow (1px tight + 16px wide) cast\n// dark halos that made gradient-backed text look smudged. A single barely-\n// there shadow is enough for edge definition; bg-media adds its own dark\n// scrim for legibility over photos, so we don't need to compensate here.\nfunction dropShadowFor(textColor: string): string {\n const dark = isLikelyDark(textColor);\n const tone = dark ? \"rgba(255,255,255,0.15)\" : \"rgba(0,0,0,0.2)\";\n return `0 1px 2px ${tone}`;\n}\n\nfunction isLikelyDark(color: string): boolean {\n // Crude luminance check — handles #rrggbb and #rgb. Anything we can't parse\n // (named colors, rgb()) defaults to \"not dark\" so the heavier shadow shows.\n const m = color.replace(\"#\", \"\");\n if (m.length === 3) {\n const r = parseInt(m[0] + m[0], 16);\n const g = parseInt(m[1] + m[1], 16);\n const b = parseInt(m[2] + m[2], 16);\n return (r * 299 + g * 587 + b * 114) / 1000 < 128;\n }\n if (m.length === 6) {\n const r = parseInt(m.slice(0, 2), 16);\n const g = parseInt(m.slice(2, 4), 16);\n const b = parseInt(m.slice(4, 6), 16);\n return (r * 299 + g * 587 + b * 114) / 1000 < 128;\n }\n return false;\n}\n// ─── Font sizing matrix ─────────────────────────────────────────\n// Mirrors what production templates actually render today, ported from:\n// - text-overlay.tsx (headline: 80/64/48 × s_min by char count)\n// - infographic-steps.tsx (caption: ~44 × s_min capped by layout)\n\n/**\n * Compute heroWord font size for a single word. Each active word fills its\n * own moment — trailer convention. Center can go large (480ref cap); top\n * stays inside the top zone height so it doesn't crash into the animation\n * or data viz below.\n */\nfunction computeHeroFontSize(\n wordChars: number,\n position: TextPosition,\n width: number,\n height: number,\n safeZone: SafeZone,\n): number {\n const chars = Math.max(wordChars, 1);\n const s_min = Math.min(width, height) / 1080;\n const widthBudget = (width - safeZone.left - safeZone.right) * 0.92;\n const pxPerChar = 0.58; // bold sans-serif approximation\n const widthCap = widthBudget / (chars * pxPerChar);\n\n if (position === \"center\") {\n return Math.min(480 * s_min, widthCap);\n }\n\n // Top/bottom: keep the word inside its zone (~28% of frame height) with\n // 80% headroom for entrance overshoot + breathe. Reference target 220ref\n // so even short words stay big without overflowing the zone.\n const heightCap = height * 0.28 * 0.8;\n return Math.min(220 * s_min, widthCap, heightCap);\n}\n\n/**\n * Smooth interpolation between max and min font size based on character count.\n * Avoids the visible \"jump\" you get from bucket boundaries when copy length\n * crosses a threshold (e.g., 25→26 chars dropping headline from 80px to 64px).\n *\n * Exported so templates that lay out their own text can match the headline\n * curve instead of inventing their own bucketed scaling.\n *\n * Returns size at 1080-reference scale; caller multiplies by s_min.\n */\nexport function smoothSize(chars: number, maxChars: number, max: number, min: number): number {\n const t = Math.max(0, Math.min(1, chars / maxChars));\n // Slight curve so short text stays at maxSize longer before scaling down.\n const eased = t * t;\n return max - (max - min) * eased;\n}\n\nfunction computeFontSize(\n archetype: TextArchetype,\n text: string,\n role: TextSizeRole,\n position: TextPosition,\n width: number,\n height: number,\n safeZone: SafeZone,\n): { fontSize: number; fontWeight: number } {\n const s_min = Math.min(width, height) / 1080;\n\n // heroWord container fontSize uses the longest word as a safe fallback. The\n // ACTIVE-word size is recomputed per-render in the \"hero\" render branch\n // (via computeHeroFontSize) so each word fills its own moment optimally —\n // trailer convention.\n if (archetype === \"heroWord\") {\n const longestChars = text\n .split(/\\s+/)\n .filter(Boolean)\n .reduce((m, w) => Math.max(m, w.length), 1);\n return {\n fontSize: computeHeroFontSize(longestChars, position, width, height, safeZone),\n fontWeight: 800,\n };\n }\n\n const chars = text.length;\n\n // Smooth scaling, minimums set so even long copy stays readable in production\n // (1080 reference). Numbers tuned to match — but improve on — the previous\n // bucketed system.\n if (role === \"headline\") {\n // Floor 60 (was 48) — matches the typography guideline \"Titles/headlines\n // 60-86px at 1080\" and lifts long-copy headlines off the body-text floor\n // that left ProblemSolution-shaped statements feeling small. Max held at\n // 88 so short, punchy headlines still fill the frame.\n const refSize = smoothSize(chars, /* maxChars */ 70, /* max */ 88, /* min */ 60);\n return { fontSize: refSize * s_min, fontWeight: 700 };\n }\n\n if (role === \"caption\") {\n const refSize = smoothSize(chars, 80, 48, 32);\n return { fontSize: refSize * s_min, fontWeight: 600 };\n }\n\n // label\n const refSize = smoothSize(chars, 80, 34, 24);\n return { fontSize: refSize * s_min, fontWeight: 500 };\n}\n\n// ─── Positioning ───────────────────────────────────────────────\n\nfunction positionStyle(\n position: TextPosition,\n height: number,\n safeZone: SafeZone,\n): React.CSSProperties {\n switch (position) {\n case \"top\":\n return {\n top: safeZone.top + height * 0.06,\n height: height * 0.28,\n alignItems: \"flex-start\",\n };\n case \"bottom\":\n return {\n bottom: safeZone.bottom + height * 0.06,\n height: height * 0.28,\n alignItems: \"flex-end\",\n };\n case \"center\":\n default:\n return {\n top: 0,\n bottom: 0,\n alignItems: \"center\",\n };\n }\n}\n\n// ─── Component ─────────────────────────────────────────────────\n\nexport const TemplateText: React.FC<TemplateTextProps> = ({\n archetype: archetypeRaw,\n text: textRaw,\n progress,\n motionProgress = progress,\n sceneDuration,\n width,\n height,\n position = \"center\",\n sizeRole = \"headline\",\n typeTreatment,\n safeZone = DEFAULT_SAFE_ZONE,\n font = DEFAULT_FONT,\n color = \"#FFFFFF\",\n beatIntensity = 0,\n}) => {\n // Defensive coerce: TemplateText is downstream of ~16 templates that pass\n // their own `variables.X` strings. If any one of them passes undefined\n // (missing variable on a freshly added scene, stale saved config, custom\n // template not setting a field), the unguarded `.split` / `.length` calls\n // below crash the entire preview. Treat undefined/non-string as\n // empty so a single bad scene doesn't take everything down. Warn in dev\n // so the upstream gap still surfaces.\n const textSafe = typeof textRaw === \"string\" ? textRaw : \"\";\n const isDevelopment = (import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV;\n if (textRaw !== undefined && typeof textRaw !== \"string\" && isDevelopment) {\n console.warn(\"[TemplateText] received non-string text:\", textRaw);\n }\n // `|` is the AI's explicit line-break convention for headline copy\n // (\"Built for speed.|Designed for you.\"). Convert centrally so EVERY\n // template that renders text through TemplateText honors it — bg-media\n // used to convert locally while confetti/emojiBurst/etc. rendered the\n // pipe literally. Whitespace around the pipe is trimmed so spaced and\n // unspaced pipes produce identical output. The container's\n // `white-space: pre-line` renders the resulting `\\n` as a hard break.\n // Templates that legitimately render pipes (code, terminal commands)\n // don't flow through TemplateText, so they're unaffected.\n const text = textSafe.replace(/\\s*\\|\\s*/g, \"\\n\");\n // Normalize the archetype prop so unknown names fall back safely.\n const archetype = normalizeArchetype(archetypeRaw);\n const scale = Math.min(width, height) / 1080;\n // Motion pacing applies at every size role — a calm video should ease its\n // captions in too, not just its headlines. (The rest of the treatment is\n // headline-only; see `tt` below.)\n const result: ArchetypeRender = renderArchetype(\n archetype,\n progress,\n scale,\n text,\n sceneDuration,\n typeTreatment?.phaseScale ?? 1,\n motionProgress,\n );\n const { fontSize, fontWeight } = computeFontSize(\n archetype,\n text,\n sizeRole,\n position,\n width,\n height,\n safeZone,\n );\n\n // beatIntensity reserved for future use; currently a no-op on text body.\n void beatIntensity;\n\n const baseTypo = TYPO[sizeRole];\n // Preset type treatment. Absent (or the default preset's zero-deltas) leaves\n // every value exactly as it was, so unpresetted configs are unaffected.\n const tt = sizeRole === \"headline\" ? typeTreatment : undefined;\n // Only rewrite a value the preset actually changes — reformatting\n // letterSpacing with a zero delta would alter the emitted string (and every\n // stability snapshot) without changing the render.\n const typo = tt\n ? {\n ...baseTypo,\n ...(tt.trackingDeltaEm !== 0\n ? {\n letterSpacing: `${Number(\n (parseFloat(baseTypo.letterSpacing) + tt.trackingDeltaEm).toFixed(4),\n )}em`,\n }\n : {}),\n ...(tt.transform ? { textTransform: tt.transform } : {}),\n }\n : baseTypo;\n const presetWeight = tt ? Math.min(900, Math.max(100, fontWeight + tt.weightDelta)) : fontWeight;\n const presetSize = tt ? fontSize * tt.sizeScale : fontSize;\n const textShadow = dropShadowFor(color);\n\n const containerStyle: React.CSSProperties = {\n position: \"absolute\",\n left: 0,\n right: 0,\n display: \"flex\",\n justifyContent: \"center\",\n padding: `0 ${safeZone.right}px 0 ${safeZone.left}px`,\n color,\n fontFamily: font,\n fontWeight: presetWeight,\n fontSize: presetSize,\n textAlign: \"center\",\n pointerEvents: \"none\",\n fontFeatureSettings: FONT_FEATURES,\n textRendering: \"optimizeLegibility\",\n WebkitFontSmoothing: \"antialiased\",\n MozOsxFontSmoothing: \"grayscale\",\n // Respect explicit newlines — the centralized `|` → `\\n` conversion\n // above (and callers passing real newlines) rely on this. Multiple\n // spaces still collapse normally; only `\\n` and CRLF break.\n whiteSpace: \"pre-line\",\n ...(textShadow ? { textShadow } : {}),\n ...typo,\n ...positionStyle(position, height, safeZone),\n };\n\n if (result.kind === \"block\") {\n return (\n <div style={containerStyle}>\n <div\n style={{\n opacity: result.block.opacity,\n transform: `${result.block.transform}`,\n // Inherit letter-spacing from the container's typography defaults\n // unless the archetype explicitly overrides (e.g., for animated tracking).\n ...(result.block.letterSpacing ? { letterSpacing: result.block.letterSpacing } : {}),\n ...(result.block.willChange ? { willChange: result.block.willChange } : {}),\n maxWidth: \"85%\",\n }}\n >\n {renderWithEmoji(result.text, fontSize)}\n </div>\n </div>\n );\n }\n\n if (result.kind === \"typewriter\") {\n // Render every character as its own span so the FULL TEXT always sets the\n // layout — wrapping is decided by the complete string, not the typed\n // prefix. The cursor is overlaid with position:absolute from the last\n // typed char so it doesn't break the word it's inside.\n const chars = text.split(\"\");\n // Map cluster-start code-unit indices → full emoji graphemes so emoji use\n // the native font even in the per-char typewriter reveal. Indexing\n // stays on text.length (UTF-16 units) so result.visibleChars / charExits\n // line up exactly; continuation units of a cluster render nothing.\n const emojiPlan = planTypewriterEmoji(text);\n const cursorBar = {\n position: \"absolute\" as const,\n width: \"0.08em\",\n height: \"0.88em\",\n background: \"currentColor\",\n borderRadius: \"0.01em\",\n pointerEvents: \"none\" as const,\n };\n return (\n <div style={containerStyle}>\n <div\n style={{\n opacity: result.opacity,\n maxWidth: \"85%\",\n whiteSpace: \"pre-wrap\",\n position: \"relative\",\n }}\n >\n {chars.map((ch, i) => {\n const isTyped = i < result.visibleChars;\n const isLastTyped = i === result.visibleChars - 1;\n const anchorCursor = isLastTyped && result.cursor;\n const charExit = result.charExits?.[i];\n const baseOpacity = isTyped ? 1 : 0;\n const finalOpacity = baseOpacity * (charExit?.opacity ?? 1);\n // During exit the per-char span needs inline-block so translateX\n // takes effect; whiteSpace: pre keeps space chars from collapsing.\n const exitStyle = charExit\n ? {\n display: \"inline-block\" as const,\n transform: `translateX(${charExit.translateX}px)`,\n whiteSpace: \"pre\" as const,\n }\n : null;\n // Emoji handling: a cluster-start unit renders the full grapheme;\n // its continuation units render nothing.\n const emojiChar = emojiPlan?.starts.get(i);\n if (emojiPlan?.covered.has(i)) return null;\n return (\n <span\n key={i}\n style={{\n opacity: finalOpacity,\n position: anchorCursor ? \"relative\" : \"static\",\n ...(exitStyle ?? {}),\n }}\n >\n {emojiChar ? <Emoji char={emojiChar} size={fontSize} /> : ch}\n {anchorCursor && (\n <span\n aria-hidden\n style={{\n ...cursorBar,\n left: \"100%\",\n top: \"0.08em\",\n marginLeft: \"0.12em\",\n }}\n />\n )}\n </span>\n );\n })}\n {result.visibleChars === 0 && result.cursor && (\n <span\n aria-hidden\n style={{\n ...cursorBar,\n left: 0,\n top: \"0.08em\",\n }}\n />\n )}\n </div>\n </div>\n );\n }\n\n if (result.kind === \"words\") {\n // Render words inline-block with REAL space chars between them — word\n // spacing inherits from the container's typography defaults, matching\n // every other archetype's wrap behavior.\n return (\n <div style={containerStyle}>\n <div\n style={{\n opacity: result.blockOpacity,\n transform: `${result.blockTransform} `,\n maxWidth: \"85%\",\n }}\n >\n {result.words.map((w, i) => (\n <span key={i}>\n <span\n style={{\n display: \"inline-block\",\n opacity: w.style.opacity,\n transform: w.style.transform,\n }}\n >\n {renderWithEmoji(w.text, fontSize)}\n </span>\n {i < result.words.length - 1 ? \" \" : \"\"}\n </span>\n ))}\n </div>\n </div>\n );\n }\n\n if (result.kind === \"hero\") {\n // Per-word sizing: each active word fills its own moment optimally.\n const perWordFontSize = computeHeroFontSize(\n result.word.length,\n position,\n width,\n height,\n safeZone,\n );\n // Fixed-height slot so words of different sizes don't jump vertically.\n // Slot is the max possible hero size for this position; lineHeight: 1 on\n // the inner word locks the glyph box to the font height so flex-center\n // lands the glyph at the same Y for every word.\n const slotHeight =\n position === \"center\" ? 480 * scale : Math.min(220 * scale, height * 0.28 * 0.8);\n return (\n <div style={{ ...containerStyle, fontSize: perWordFontSize }}>\n <div\n style={{\n height: slotHeight,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n }}\n >\n <div\n style={{\n opacity: result.opacity,\n transform: `${result.transform} `,\n lineHeight: 1,\n ...(result.letterSpacing ? { letterSpacing: result.letterSpacing } : {}),\n }}\n >\n {renderWithEmoji(result.word, perWordFontSize)}\n </div>\n </div>\n </div>\n );\n }\n\n return null;\n};\n"
53
+ },
54
+ {
55
+ "path": "src/visual-system/emoji/index.tsx",
56
+ "type": "registry:component",
57
+ "target": "vanillasky/emoji/index.tsx",
58
+ "content": "import * as React from \"react\";\n\nconst NATIVE_EMOJI_FONT = \"Apple Color Emoji,Segoe UI Emoji,Noto Color Emoji,sans-serif\";\nconst NATIVE_EMOJI = /\\p{Emoji_Presentation}|\\p{Regional_Indicator}|\\u{FE0F}|\\u{20E3}/u;\n\n/** True for emoji-presentation graphemes, not plain symbols such as ★ or ✓. */\nexport function isNativeEmoji(char: string): boolean {\n return NATIVE_EMOJI.test(char);\n}\n\nexport interface EmojiProps {\n /** The emoji character or grapheme cluster, e.g. \"🎉\" or \"👩‍💻\". */\n char: string;\n /** Font size in px. */\n size: number;\n /** vertical-align for inline flow. Defaults to a glyph-like baseline nudge. */\n verticalAlign?: React.CSSProperties[\"verticalAlign\"];\n /** Extra styles merged onto the native emoji span. */\n style?: React.CSSProperties;\n}\n\n/** Render emoji with the viewer's OS font while preserving text-style symbols. */\nexport const Emoji: React.FC<EmojiProps> = ({ char, size, verticalAlign = \"-0.15em\", style }) => {\n const nativeEmoji = isNativeEmoji(char);\n return (\n <span\n role={nativeEmoji ? \"img\" : undefined}\n aria-label={nativeEmoji ? char : undefined}\n style={{\n ...(nativeEmoji ? { fontFamily: NATIVE_EMOJI_FONT } : {}),\n fontSize: size,\n lineHeight: 1,\n display: \"inline-block\",\n verticalAlign,\n ...style,\n }}\n >\n {char}\n </span>\n );\n};\n"
59
+ },
60
+ {
61
+ "path": "src/visual-system/emoji/emoji-text.tsx",
62
+ "type": "registry:component",
63
+ "target": "vanillasky/emoji/emoji-text.tsx",
64
+ "content": "/**\n * EmojiText / renderWithEmoji — split a string into text runs + emoji runs,\n * rendering each emoji with the viewer's native emoji font while leaving\n * everything else as plain text.\n *\n * Segmentation: grapheme clusters via Intl.Segmenter so ZWJ sequences\n * (👩‍💻) and skin-tone modifiers (👍🏽) stay single units. Adjacent emoji\n * clusters each render as their own span in sequence.\n */\n\nimport * as React from \"react\";\nimport { Emoji, isNativeEmoji } from \"./index\";\n\n/** Split text into grapheme clusters (ZWJ + skin-tone safe). */\nfunction toGraphemes(text: string): string[] {\n const Seg = (Intl as unknown as { Segmenter?: typeof Intl.Segmenter }).Segmenter;\n if (Seg) {\n const seg = new Seg(undefined, { granularity: \"grapheme\" });\n const out: string[] = [];\n for (const { segment } of seg.segment(text)) out.push(segment);\n return out;\n }\n // Fallback: code-point spread. Won't keep ZWJ/skin-tone as single units, so\n // those land as separate clusters. Modern browsers all have Segmenter.\n return Array.from(text);\n}\n\nexport interface RenderWithEmojiOptions {\n /** vertical-align passed to each <Emoji>. */\n verticalAlign?: React.CSSProperties[\"verticalAlign\"];\n /** Extra style merged onto each emoji span. */\n emojiStyle?: React.CSSProperties;\n}\n\n/**\n * Split `text` into an array of React nodes — plain-text strings interleaved\n * with <Emoji> spans for every emoji cluster. Returns a single-element\n * `[text]` fast-path when there are no emoji (the common case), so\n * non-emoji text pays ~one regex test.\n *\n * @param text the string to render\n * @param fontSizePx surrounding font size in px (emoji box = this size)\n */\nexport function renderWithEmoji(\n text: string,\n fontSizePx: number,\n opts?: RenderWithEmojiOptions,\n): React.ReactNode[] {\n if (!text) return [text];\n // Cheap bail-out: if the whole string has no emoji-range codepoint, return\n // it untouched (no segmentation, no array churn) — the hot path for the vast\n // majority of titles/bodies.\n if (!isNativeEmoji(text)) return [text];\n\n const clusters = toGraphemes(text);\n const nodes: React.ReactNode[] = [];\n let textBuf = \"\";\n let key = 0;\n\n const flushText = () => {\n if (textBuf) {\n nodes.push(textBuf);\n textBuf = \"\";\n }\n };\n\n for (const cluster of clusters) {\n if (isNativeEmoji(cluster)) {\n flushText();\n nodes.push(\n <Emoji\n key={`e${key++}`}\n char={cluster}\n size={fontSizePx}\n verticalAlign={opts?.verticalAlign}\n style={opts?.emojiStyle}\n />,\n );\n } else {\n textBuf += cluster;\n }\n }\n flushText();\n return nodes;\n}\n\n/**\n * Per-code-unit emoji plan for the typewriter archetype, which reveals text\n * one UTF-16 code unit at a time and indexes by `text.length`. We can't just\n * call renderWithEmoji there (it would re-segment and desync the visibleChars\n * counter), so this maps each cluster's START code-unit index to the full emoji\n * and marks the cluster's CONTINUATION indices as covered (render nothing for\n * them — the start index's span already contains the whole cluster).\n *\n * Returns null when the text contains no emoji (the common case),\n * so the typewriter render keeps its plain per-char path.\n */\nexport interface EmojiTypewriterPlan {\n /** code-unit index → full emoji cluster char (cluster start). */\n starts: Map<number, string>;\n /** code-unit indices that are continuations of a cluster (render nothing). */\n covered: Set<number>;\n}\n\nexport function planTypewriterEmoji(text: string): EmojiTypewriterPlan | null {\n if (!text || !isNativeEmoji(text)) return null;\n const starts = new Map<number, string>();\n const covered = new Set<number>();\n let idx = 0;\n let found = false;\n for (const cluster of toGraphemes(text)) {\n const len = cluster.length; // UTF-16 code units\n if (isNativeEmoji(cluster)) {\n found = true;\n // Store the WHOLE cluster char (not just the start unit) so callers can\n // pass it straight to <Emoji> without splitting a grapheme cluster.\n starts.set(idx, cluster);\n for (let k = 1; k < len; k++) covered.add(idx + k);\n }\n idx += len;\n }\n return found ? { starts, covered } : null;\n}\n\nexport interface EmojiTextProps {\n /** The text to render with emoji in the native operating-system font. */\n children: string;\n /** Surrounding font size in px — sizes each emoji to match a glyph. */\n fontSize: number;\n verticalAlign?: React.CSSProperties[\"verticalAlign\"];\n emojiStyle?: React.CSSProperties;\n}\n\n/**\n * Inline wrapper around renderWithEmoji. Renders a React.Fragment of text +\n * emoji spans. Use where a component currently renders a raw `{text}`\n * child and you have the surrounding font size in px.\n */\nexport const EmojiText: React.FC<EmojiTextProps> = ({\n children,\n fontSize,\n verticalAlign,\n emojiStyle,\n}) => {\n const text = typeof children === \"string\" ? children : \"\";\n return <>{renderWithEmoji(text, fontSize, { verticalAlign, emojiStyle })}</>;\n};\n"
65
+ },
66
+ {
67
+ "path": "src/visual-system/scene-templates/types.ts",
68
+ "type": "registry:lib",
69
+ "target": "vanillasky/scene-templates/types.ts",
70
+ "content": "/**\n * Scene template types.\n *\n * A template is a reusable React component that defines how a scene looks.\n * It declares the variables an editor or model may provide\n * and receives universal settings as props.\n *\n * Templates are searchable by AI via description, category, jobs, register,\n * and useWhen guidance.\n * The variable schema enables any LLM to fill in template variables via JSON.\n */\n\nimport type { ResolvedTokens } from \"../theme\";\nimport type { SafeZone, TemplateStyle } from \"../template-context\";\n\n/**\n * Props passed to every scene template component.\n *\n * All animation must be driven by `progress` (0→1). No CSS animations,\n * no Framer Motion, no requestAnimationFrame. Use interpolate/spring\n * from animation-utils.ts.\n *\n * Scale factor: use `Math.min(width, height) / 1080` — normalizes to\n * the short edge so visuals are consistent across portrait and landscape.\n */\nexport interface SceneTemplateProps {\n variables: Record<string, unknown>;\n style: TemplateStyle;\n /** 0→1 through the scene's duration */\n progress: number;\n /** Presentation clock. Active templates receive the same complete 0→1 timeline as progress. */\n motionProgress?: number;\n /** 0→1 beat pulse intensity */\n beatIntensity: number;\n /** 1080 (portrait) or 1920 (landscape) */\n width: number;\n /** 1920 (portrait) or 1080 (landscape) */\n height: number;\n /** Video-level default text effect (for templates that opt in via usesGlobalTextEffect) */\n textArchetype?: string;\n /** How text leaves the scene (fade / shrink / pop / blur-scale). Falls back to a sensible default per textArchetype when undefined. */\n /** Video-level default background effect (for templates that opt in via usesGlobalBackgroundEffect) */\n backgroundEffect?: string;\n /** Platform-aware safe zone insets in pixels — use for text placement */\n safeZone: SafeZone;\n /** Scene duration in seconds — use for time-based (not progress-based) animations */\n sceneDuration?: number;\n /**\n * Brand tokens already resolved from `style`. Built-in templates import\n * resolveTokens directly; an ejected `custom_*` scene can't import anything,\n * so without this it has no way to reach the same values and ends up\n * hardcoding white, black and shadows — the body then looks generic next to\n * a frame that IS using the brand.\n */\n tokens?: ResolvedTokens;\n /**\n * True when the preview player is actively advancing progress; false when paused.\n * Templates that play HTML5 <video> elements should pause them when this is false.\n * Undefined (export capture path) is treated as true.\n */\n isPlaying?: boolean;\n}\n"
71
+ }
72
+ ],
73
+ "meta": {
74
+ "vanillasky": {
75
+ "layer": "template",
76
+ "tier": "free",
77
+ "register": "typography-led",
78
+ "jobs": [
79
+ "claim",
80
+ "proof"
81
+ ],
82
+ "useWhen": "One exact grounded number is the hero: growth, speed, rank, customer count, or reduction.",
83
+ "textCanvas": "tight",
84
+ "minDuration": 1.5,
85
+ "preferredDuration": 4,
86
+ "schema": {
87
+ "type": "object",
88
+ "properties": {
89
+ "texts": {
90
+ "type": "string",
91
+ "title": "Text",
92
+ "description": "Concise title shown above the metric (48 characters maximum).",
93
+ "minLength": 1,
94
+ "maxLength": 48,
95
+ "default": "Our biggest milestone yet."
96
+ },
97
+ "value": {
98
+ "type": "number",
99
+ "title": "Target number",
100
+ "description": "The number to count up to",
101
+ "default": 1000
102
+ },
103
+ "decimalPlaces": {
104
+ "type": "number",
105
+ "title": "Decimal places",
106
+ "description": "Optional display precision; when omitted, precision is inferred from the value"
107
+ },
108
+ "confetti": {
109
+ "type": "boolean",
110
+ "title": "Celebrate with confetti",
111
+ "description": "Add a confetti burst when the metric is explicitly celebratory",
112
+ "default": false
113
+ },
114
+ "label": {
115
+ "type": "string",
116
+ "title": "Label",
117
+ "description": "Short label displayed below the number (32 characters maximum).",
118
+ "minLength": 1,
119
+ "maxLength": 32,
120
+ "default": "Total users"
121
+ },
122
+ "prefix": {
123
+ "type": "string",
124
+ "title": "Prefix",
125
+ "description": "Compact prefix before the number (e.g. $, €; 2 characters maximum).",
126
+ "maxLength": 2,
127
+ "default": ""
128
+ },
129
+ "unit": {
130
+ "type": "string",
131
+ "title": "Unit suffix",
132
+ "description": "Compact suffix after the number (e.g. %, +, k, M; 4 characters maximum).",
133
+ "maxLength": 4,
134
+ "default": ""
135
+ },
136
+ "mediaUrl": {
137
+ "type": "string",
138
+ "title": "Background media",
139
+ "description": "Optional photo or video URL behind this scene. When set, replaces the brand gradient.",
140
+ "format": "uri",
141
+ "default": ""
142
+ },
143
+ "mediaKeyword": {
144
+ "type": "string",
145
+ "title": "Background search keyword",
146
+ "description": "2-4 word English term for Pexels stock-footage search (auto-fills mediaUrl).",
147
+ "format": "stock-media-keyword",
148
+ "default": ""
149
+ },
150
+ "mediaType": {
151
+ "type": "string",
152
+ "title": "Background media type",
153
+ "description": "auto detects photo/video from URL. 'gradient' is a deliberate mode — atmospheric brand-color scene with no stock footage.",
154
+ "enum": [
155
+ "auto",
156
+ "photo",
157
+ "video",
158
+ "gradient"
159
+ ],
160
+ "default": "auto"
161
+ },
162
+ "mediaPoster": {
163
+ "type": "string",
164
+ "title": "Background poster image",
165
+ "description": "Still image URL shown while a video backdrop is decoding its first frame. Auto-filled from Pexels' thumbnail when fillPexelsUrls sets a video mediaUrl. Hides the gradient flash that would otherwise appear in the ~50–400ms gap between a <video> mounting and decoding its first frame.",
166
+ "format": "uri",
167
+ "default": ""
168
+ },
169
+ "mediaPosition": {
170
+ "type": "string",
171
+ "title": "Background focal position",
172
+ "description": "Controls which part of a photo or video stays visible when cover-cropped. Pick the subject's side or vertical anchor after inspecting the frame.",
173
+ "enum": [
174
+ "center",
175
+ "top",
176
+ "bottom",
177
+ "left",
178
+ "right"
179
+ ],
180
+ "default": "center"
181
+ },
182
+ "mediaTreatment": {
183
+ "type": "string",
184
+ "title": "Background contrast treatment",
185
+ "description": "subtle preserves a visual hero; cinematic adds balanced contrast; text-safe adds a stronger wash for copy-heavy scenes.",
186
+ "enum": [
187
+ "subtle",
188
+ "cinematic",
189
+ "text-safe"
190
+ ],
191
+ "default": "cinematic"
192
+ }
193
+ },
194
+ "required": [
195
+ "texts",
196
+ "value",
197
+ "label"
198
+ ],
199
+ "additionalProperties": false,
200
+ "x-vanillasky": {
201
+ "requiresStat": true,
202
+ "allowsStockMedia": true
203
+ }
204
+ },
205
+ "label": "Big number",
206
+ "description": "A single animated count-up metric with headline and label.",
207
+ "family": "Data & metrics",
208
+ "avoidWhen": "No exact number is supplied, three peer values matter equally, or the value means completion progress.",
209
+ "usesGlobalTextEffect": false,
210
+ "usesGlobalTransition": true,
211
+ "usesGlobalBackgroundEffect": true,
212
+ "timing": {
213
+ "contentFields": [
214
+ "texts",
215
+ "label"
216
+ ],
217
+ "contentUnit": "words"
218
+ },
219
+ "transitionTiming": {
220
+ "entryReadyProgress": 0.2,
221
+ "holdProgress": 0.7
222
+ }
223
+ }
224
+ }
225
+ }
@@ -0,0 +1,158 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "brandMessage",
4
+ "type": "registry:block",
5
+ "title": "Brand message",
6
+ "description": "A single iMessage-style outgoing bubble floats over media or a brand gradient.",
7
+ "dependencies": [
8
+ "react"
9
+ ],
10
+ "registryDependencies": [
11
+ "@vanillasky/backgrounds",
12
+ "@vanillasky/motion",
13
+ "@vanillasky/theme",
14
+ "@vanillasky/typography",
15
+ "@vanillasky/template-context"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "src/visual-system/scene-templates/brand-message.tsx",
20
+ "type": "registry:component",
21
+ "target": "vanillasky/scene-templates/brand-message.tsx",
22
+ "content": "/**\n * brand-message — single iMessage-style sent bubble over media.\n *\n * One outgoing chat message, right-aligned. Mirrors the chatMessenger\n * animation exactly: typing dots pop in (right side, iMessage blue),\n * bounce, fade out, then the message bubble pops in. No avatar, no\n * sender header, no tail — the same minimal iMessage rendering as\n * chatMessenger. The differentiator from chatMessenger is that this\n * runs ONE message over a media background, not a multi-message\n * conversation.\n *\n * Use cases: \"We built this for you\", \"Thanks for being here\",\n * \"Try it free this week\" — brand voice messages where you want a\n * direct, personal frame, like a single iMessage from the brand\n * dropping into the viewer's frame.\n */\n\nimport * as React from \"react\";\nimport type { SceneTemplateProps } from \"./types\";\nimport { SceneBackground, getMediaBackgroundProps } from \"./scene-background\";\nimport { stripPipe } from \"../typography\";\nimport { BrandMessageBubble } from \"../primitives/typography/BrandMessageBubble\";\n\nexport const BrandMessageTemplate: React.FC<SceneTemplateProps> = ({\n variables,\n style,\n progress,\n beatIntensity,\n width,\n height,\n sceneDuration,\n isPlaying = true,\n safeZone,\n backgroundEffect,\n}) => {\n /* Match chatMessenger's scaling so the bubble feels identical: the\n * 534-wide reference normalizes typography. Use the SHORT edge so\n * landscape (where width is the long edge) doesn't blow up — pure\n * width/534 = 3.6× on 1920×1080 was too big. Bump landscape by 1.3×\n * vs portrait so the bubble has more presence on a wider frame\n * without dominating it (final s ~= 2.6× on landscape vs ~2.0× on\n * portrait). */\n const brandName = stripPipe(style.brand.name ?? \"Your brand\");\n const message = stripPipe(String(variables.message || \"\"));\n const seed = (brandName + message).split(\"\").reduce((acc, c) => acc + c.charCodeAt(0), 0);\n\n return (\n <div\n style={{\n width,\n height,\n backgroundColor: \"#000\",\n position: \"relative\",\n overflow: \"hidden\",\n // System fonts only — see BrandMessageBubble for the why\n // (SVG-as-image can't load @font-face data: URLs, so the only\n // way to get preview/export parity is to use a font the OS\n // already has natively).\n fontFamily: '\"Helvetica Neue\", Helvetica, Arial, sans-serif',\n }}\n >\n {/* [slot: background] Gradient / media backdrop */}\n <SceneBackground\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n width={width}\n height={height}\n {...getMediaBackgroundProps(variables)}\n backgroundEffect={backgroundEffect}\n seed={seed}\n isPlaying={isPlaying}\n beatIntensity={beatIntensity}\n />\n\n {/* [slot: hero] Bubble + typing — BrandMessageBubble primitive (the\n message is the content; no separate caption slot) */}\n <BrandMessageBubble\n progress={progress}\n sceneDuration={sceneDuration ?? 4}\n brandName={brandName}\n message={message}\n width={width}\n height={height}\n safeZone={safeZone}\n />\n </div>\n );\n};\n"
23
+ },
24
+ {
25
+ "path": "src/visual-system/primitives/typography/BrandMessageBubble.tsx",
26
+ "type": "registry:component",
27
+ "target": "vanillasky/primitives/typography/BrandMessageBubble.tsx",
28
+ "content": "/**\n * BrandMessageBubble\n *\n * Single iMessage-style outgoing bubble (cream, right-aligned, sharp\n * bottom-right corner). Typing dots fade in first, then the bubble pops.\n *\n * brand-message.tsx is refactored to consume this primitive; both paths\n * render byte-identical DOM.\n */\n\nimport * as React from \"react\";\nimport { interpolate } from \"../../motion\";\nimport { stripPipe } from \"../../typography\";\nimport { renderWithEmoji } from \"../../emoji/emoji-text\";\n\nconst CLAMP = { extrapolateLeft: \"clamp\" as const, extrapolateRight: \"clamp\" as const };\nconst BUBBLE_BG = \"rgba(245,242,238,0.92)\";\nconst BUBBLE_TEXT = \"#1c1c1e\";\nconst DOT_COLOR = \"#8C8C92\";\n\nfunction easeOutBack(t: number): number {\n const c1 = 1.70158;\n const c3 = c1 + 1;\n return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);\n}\n\nconst TAIL_WITH_EMOJI = /(\\S+)(\\s+)([\\p{Extended_Pictographic}\\p{Emoji_Modifier}\\u{FE0E}\\u{FE0F}\\u{200D}]+)\\s*$/u;\n\n/**\n * Render a message and glue the trailing emoji cluster to the last word\n * via a `white-space: nowrap` span, so the emoji can NEVER wrap to its\n * own line at the bottom of the bubble.\n *\n * Why a span and not just NBSP: an earlier fix used U+00A0 (non-breaking\n * space) between the last word and the trailing emoji. NBSP works in the\n * live DOM (preview) but the modern-screenshot SVG-as-image render path\n * does NOT consistently honor NBSP — the emoji wrapped to its own line\n * in the exported MP4 even with NBSP in place (verified by extracting a\n * frame from a real export via ffmpeg). `white-space: nowrap` on a span\n * is a hard CSS rendering rule that all renderers respect.\n *\n * Scoped to the LAST trailing emoji cluster preceded by a word: messages\n * with an emoji mid-sentence still wrap naturally on width. Covers the\n * pictographic set + emoji modifiers + variation selectors (FE0E/F) +\n * ZWJ for composite emoji like the astronaut.\n */\nfunction renderMessageWithEmojiTail(text: string, fontSizePx: number): React.ReactNode {\n const match = TAIL_WITH_EMOJI.exec(text);\n if (!match || match.index === undefined) {\n // No trailing-emoji tail: still render any mid-sentence emoji with the\n // viewer's native emoji font.\n return renderWithEmoji(text, fontSizePx);\n }\n const [, lastWord, space, emoji] = match;\n const leading = text.slice(0, match.index);\n return (\n <>\n {renderWithEmoji(leading, fontSizePx)}\n <span style={{ whiteSpace: \"nowrap\" }}>\n {lastWord}\n {space}\n {renderWithEmoji(emoji, fontSizePx)}\n </span>\n </>\n );\n}\n\nfunction popIn(t: number, start: number, dur = 0.4): number {\n if (t <= start) return 0;\n if (t >= start + dur) return 1;\n return easeOutBack((t - start) / dur);\n}\n\n// ─── Typed component (direct use from templates) ────────────────\n\nexport interface BrandMessageBubbleProps {\n progress: number;\n sceneDuration: number;\n /** Sender label (italic, top-left of bubble) */\n brandName: string;\n /** Message text */\n message: string;\n width: number;\n height: number;\n safeZone: { top: number; right: number; bottom: number; left: number };\n}\n\nexport const BrandMessageBubble: React.FC<BrandMessageBubbleProps> = ({\n progress,\n sceneDuration,\n brandName: rawBrandName,\n message: rawMessage,\n width,\n height,\n safeZone,\n}) => {\n const isLandscape = width > height;\n const s = isLandscape\n ? (Math.min(width, height) / 534) * 1.3\n : width / 534;\n const brandName = stripPipe(rawBrandName);\n const message = stripPipe(rawMessage);\n const realTimeSeconds = progress * sceneDuration;\n\n const typingStart = 0.10;\n const bubbleStart = 0.38;\n const typingPop = popIn(progress, typingStart, 0.10);\n const typingFade = interpolate(progress, [bubbleStart - 0.06, bubbleStart], [1, 0], CLAMP);\n const showTyping = progress >= typingStart && progress < bubbleStart;\n\n const bubblePop = popIn(progress, bubbleStart, 0.18);\n const showBubble = progress >= bubbleStart;\n\n const topPad = Math.max(safeZone.top, height * 0.25);\n const sidePad = Math.max(safeZone.left, width * 0.06);\n\n return (\n <div\n style={{\n position: \"absolute\",\n top: topPad,\n left: sidePad,\n right: sidePad,\n display: \"flex\",\n justifyContent: \"flex-end\",\n }}\n >\n {showTyping && (\n <div\n style={{\n opacity: typingPop * typingFade,\n transform: `translateY(${(1 - typingPop) * 8 * s}px)`,\n background: BUBBLE_BG,\n borderRadius: 18 * s,\n padding: `${12 * s}px ${16 * s}px`,\n display: \"flex\",\n gap: 5 * s,\n alignItems: \"center\",\n boxShadow: `0 ${1 * s}px ${4 * s}px rgba(0,0,0,0.10)`,\n }}\n >\n {[0, 1, 2].map((j) => {\n const phase = (realTimeSeconds * 1.6 + j * 0.2) % 1;\n const y = Math.sin(phase * Math.PI * 2) * 2.5 * s;\n const op = 0.45 + 0.55 * Math.max(0, Math.sin(phase * Math.PI));\n return (\n <div\n key={j}\n style={{\n width: 7 * s,\n height: 7 * s,\n borderRadius: 3.5 * s,\n background: DOT_COLOR,\n transform: `translateY(${-y}px)`,\n opacity: op,\n }}\n />\n );\n })}\n </div>\n )}\n\n {showBubble && (\n <div\n style={{\n // `width: max-content` forces the bubble to size to its\n // natural one-line content width up to `maxWidth`. Without\n // this, modern-screenshot's foreignObject text shaping (the\n // export path) was using an intrinsic width well below\n // `maxWidth`, breaking text that would have fit on one line\n // in the live DOM — even after the NBSP fix glued the\n // trailing emoji to the last word. With `max-content` +\n // `maxWidth`, the bubble grows up to the cap on a single\n // line, then wraps naturally inside the cap for genuinely\n // long messages.\n width: \"max-content\",\n maxWidth: width * 0.86,\n // Render with native system fonts in BOTH paths. The export\n // pipeline turns the iframe DOM into an SVG and loads it via\n // <img src=\"data:image/svg+xml,…\">, and SVG-as-image is a\n // sandboxed graphics context that does NOT load @font-face\n // resources (data: or otherwise — this is a browser security\n // model, not a CSP issue). Inter therefore never resolves in\n // the export; the SVG falls back to Helvetica/Helvetica Neue,\n // which is wider at the same point size, and \"We built this\n // for you ❤️\" wraps inside the pinned bubble box even though\n // the iframe's Inter rendering fit on one line. Naming the\n // system font explicitly here makes the iframe ALSO use that\n // same OS-native font, so preview and export compute identical\n // widths and both fit on one line. See PRs #598/#599/#604 for\n // the months of font-embed work that this finally closes out.\n fontFamily: '\"Helvetica Neue\", Helvetica, Arial, sans-serif',\n background: BUBBLE_BG,\n color: BUBBLE_TEXT,\n borderRadius: `${22 * s}px ${22 * s}px 0 ${22 * s}px`,\n padding: `${14 * s}px ${18 * s}px ${12 * s}px`,\n fontSize: 23 * s,\n lineHeight: `${29 * s}px`,\n fontWeight: 400,\n opacity: bubblePop,\n transform: `translateY(${(1 - bubblePop) * 8 * s}px) scale(${0.96 + 0.04 * bubblePop})`,\n transformOrigin: \"right top\",\n boxShadow: `0 ${2 * s}px ${10 * s}px rgba(0,0,0,0.14)`,\n }}\n >\n <div\n style={{\n fontSize: 14 * s,\n fontStyle: \"italic\",\n fontWeight: 400,\n color: \"rgba(0,0,0,0.45)\",\n lineHeight: 1.2,\n marginBottom: 6 * s,\n }}\n >\n {brandName}\n </div>\n {renderMessageWithEmojiTail(message, 23 * s)}\n </div>\n )}\n </div>\n );\n};\n"
29
+ },
30
+ {
31
+ "path": "src/visual-system/emoji/emoji-text.tsx",
32
+ "type": "registry:component",
33
+ "target": "vanillasky/emoji/emoji-text.tsx",
34
+ "content": "/**\n * EmojiText / renderWithEmoji — split a string into text runs + emoji runs,\n * rendering each emoji with the viewer's native emoji font while leaving\n * everything else as plain text.\n *\n * Segmentation: grapheme clusters via Intl.Segmenter so ZWJ sequences\n * (👩‍💻) and skin-tone modifiers (👍🏽) stay single units. Adjacent emoji\n * clusters each render as their own span in sequence.\n */\n\nimport * as React from \"react\";\nimport { Emoji, isNativeEmoji } from \"./index\";\n\n/** Split text into grapheme clusters (ZWJ + skin-tone safe). */\nfunction toGraphemes(text: string): string[] {\n const Seg = (Intl as unknown as { Segmenter?: typeof Intl.Segmenter }).Segmenter;\n if (Seg) {\n const seg = new Seg(undefined, { granularity: \"grapheme\" });\n const out: string[] = [];\n for (const { segment } of seg.segment(text)) out.push(segment);\n return out;\n }\n // Fallback: code-point spread. Won't keep ZWJ/skin-tone as single units, so\n // those land as separate clusters. Modern browsers all have Segmenter.\n return Array.from(text);\n}\n\nexport interface RenderWithEmojiOptions {\n /** vertical-align passed to each <Emoji>. */\n verticalAlign?: React.CSSProperties[\"verticalAlign\"];\n /** Extra style merged onto each emoji span. */\n emojiStyle?: React.CSSProperties;\n}\n\n/**\n * Split `text` into an array of React nodes — plain-text strings interleaved\n * with <Emoji> spans for every emoji cluster. Returns a single-element\n * `[text]` fast-path when there are no emoji (the common case), so\n * non-emoji text pays ~one regex test.\n *\n * @param text the string to render\n * @param fontSizePx surrounding font size in px (emoji box = this size)\n */\nexport function renderWithEmoji(\n text: string,\n fontSizePx: number,\n opts?: RenderWithEmojiOptions,\n): React.ReactNode[] {\n if (!text) return [text];\n // Cheap bail-out: if the whole string has no emoji-range codepoint, return\n // it untouched (no segmentation, no array churn) — the hot path for the vast\n // majority of titles/bodies.\n if (!isNativeEmoji(text)) return [text];\n\n const clusters = toGraphemes(text);\n const nodes: React.ReactNode[] = [];\n let textBuf = \"\";\n let key = 0;\n\n const flushText = () => {\n if (textBuf) {\n nodes.push(textBuf);\n textBuf = \"\";\n }\n };\n\n for (const cluster of clusters) {\n if (isNativeEmoji(cluster)) {\n flushText();\n nodes.push(\n <Emoji\n key={`e${key++}`}\n char={cluster}\n size={fontSizePx}\n verticalAlign={opts?.verticalAlign}\n style={opts?.emojiStyle}\n />,\n );\n } else {\n textBuf += cluster;\n }\n }\n flushText();\n return nodes;\n}\n\n/**\n * Per-code-unit emoji plan for the typewriter archetype, which reveals text\n * one UTF-16 code unit at a time and indexes by `text.length`. We can't just\n * call renderWithEmoji there (it would re-segment and desync the visibleChars\n * counter), so this maps each cluster's START code-unit index to the full emoji\n * and marks the cluster's CONTINUATION indices as covered (render nothing for\n * them — the start index's span already contains the whole cluster).\n *\n * Returns null when the text contains no emoji (the common case),\n * so the typewriter render keeps its plain per-char path.\n */\nexport interface EmojiTypewriterPlan {\n /** code-unit index → full emoji cluster char (cluster start). */\n starts: Map<number, string>;\n /** code-unit indices that are continuations of a cluster (render nothing). */\n covered: Set<number>;\n}\n\nexport function planTypewriterEmoji(text: string): EmojiTypewriterPlan | null {\n if (!text || !isNativeEmoji(text)) return null;\n const starts = new Map<number, string>();\n const covered = new Set<number>();\n let idx = 0;\n let found = false;\n for (const cluster of toGraphemes(text)) {\n const len = cluster.length; // UTF-16 code units\n if (isNativeEmoji(cluster)) {\n found = true;\n // Store the WHOLE cluster char (not just the start unit) so callers can\n // pass it straight to <Emoji> without splitting a grapheme cluster.\n starts.set(idx, cluster);\n for (let k = 1; k < len; k++) covered.add(idx + k);\n }\n idx += len;\n }\n return found ? { starts, covered } : null;\n}\n\nexport interface EmojiTextProps {\n /** The text to render with emoji in the native operating-system font. */\n children: string;\n /** Surrounding font size in px — sizes each emoji to match a glyph. */\n fontSize: number;\n verticalAlign?: React.CSSProperties[\"verticalAlign\"];\n emojiStyle?: React.CSSProperties;\n}\n\n/**\n * Inline wrapper around renderWithEmoji. Renders a React.Fragment of text +\n * emoji spans. Use where a component currently renders a raw `{text}`\n * child and you have the surrounding font size in px.\n */\nexport const EmojiText: React.FC<EmojiTextProps> = ({\n children,\n fontSize,\n verticalAlign,\n emojiStyle,\n}) => {\n const text = typeof children === \"string\" ? children : \"\";\n return <>{renderWithEmoji(text, fontSize, { verticalAlign, emojiStyle })}</>;\n};\n"
35
+ },
36
+ {
37
+ "path": "src/visual-system/emoji/index.tsx",
38
+ "type": "registry:component",
39
+ "target": "vanillasky/emoji/index.tsx",
40
+ "content": "import * as React from \"react\";\n\nconst NATIVE_EMOJI_FONT = \"Apple Color Emoji,Segoe UI Emoji,Noto Color Emoji,sans-serif\";\nconst NATIVE_EMOJI = /\\p{Emoji_Presentation}|\\p{Regional_Indicator}|\\u{FE0F}|\\u{20E3}/u;\n\n/** True for emoji-presentation graphemes, not plain symbols such as ★ or ✓. */\nexport function isNativeEmoji(char: string): boolean {\n return NATIVE_EMOJI.test(char);\n}\n\nexport interface EmojiProps {\n /** The emoji character or grapheme cluster, e.g. \"🎉\" or \"👩‍💻\". */\n char: string;\n /** Font size in px. */\n size: number;\n /** vertical-align for inline flow. Defaults to a glyph-like baseline nudge. */\n verticalAlign?: React.CSSProperties[\"verticalAlign\"];\n /** Extra styles merged onto the native emoji span. */\n style?: React.CSSProperties;\n}\n\n/** Render emoji with the viewer's OS font while preserving text-style symbols. */\nexport const Emoji: React.FC<EmojiProps> = ({ char, size, verticalAlign = \"-0.15em\", style }) => {\n const nativeEmoji = isNativeEmoji(char);\n return (\n <span\n role={nativeEmoji ? \"img\" : undefined}\n aria-label={nativeEmoji ? char : undefined}\n style={{\n ...(nativeEmoji ? { fontFamily: NATIVE_EMOJI_FONT } : {}),\n fontSize: size,\n lineHeight: 1,\n display: \"inline-block\",\n verticalAlign,\n ...style,\n }}\n >\n {char}\n </span>\n );\n};\n"
41
+ },
42
+ {
43
+ "path": "src/visual-system/scene-templates/scene-background.tsx",
44
+ "type": "registry:component",
45
+ "target": "vanillasky/scene-templates/scene-background.tsx",
46
+ "content": "/**\n * SceneBackground — shared backdrop component for any scene template that\n * wants to support both a brand-color gradient and stock media (Pexels\n * photo / video) as an alternate atmosphere.\n *\n * Usage:\n * <SceneBackground\n * style={style}\n * progress={progress}\n * sceneDuration={sceneDuration}\n * width={width}\n * height={height}\n * mediaUrl={String(variables.mediaUrl || \"\")}\n * mediaType={String(variables.mediaType || \"auto\")}\n * seed={String(variables.texts || \"\")}\n * isPlaying={isPlaying}\n * />\n * ... template's content layered on top\n *\n * Behavior:\n * - Brand gradient is the always-on backdrop (uses BrandGradientOverlay).\n * - When mediaUrl is set and mediaType isn't \"gradient\", the photo/video\n * covers the gradient. Vignette + bottom-half darken give the content\n * contrast against busy footage.\n * - mediaType=\"gradient\" deliberately ignores mediaUrl and renders only\n * the brand gradient. First-class atmospheric mode.\n * - When mediaUrl is empty / 404s / Pexels search returned nothing,\n * gradient shows through cleanly (matches every other gradient-backed\n * template).\n *\n * Extracted from bg-media.tsx so any template can compose it. bg-media\n * now uses this component too — its \"media is the scene\" identity comes\n * from how it positions the title (centered, full-frame), not from\n * duplicated render logic.\n */\n\nimport React, { useEffect, useRef } from \"react\";\nimport type { TemplateStyle } from \"../template-context\";\nimport { BrandGradientOverlay } from \"../backgrounds\";\nimport { getBackgroundTransform } from \"../backgrounds\";\n\nconst VIDEO_EXTENSIONS = [\".mp4\", \".webm\", \".mov\", \".m4v\", \".avi\"];\n\nfunction isVideoUrl(url: string): boolean {\n try {\n const pathname = new URL(url).pathname.toLowerCase();\n return VIDEO_EXTENSIONS.some((ext) => pathname.endsWith(ext));\n } catch {\n const lower = url.toLowerCase();\n return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext));\n }\n}\n\nexport type ResolvedMediaType = \"photo\" | \"video\" | \"gradient\";\n\nexport type MediaPosition = \"center\" | \"top\" | \"bottom\" | \"left\" | \"right\";\nexport type MediaTreatment = \"subtle\" | \"cinematic\" | \"text-safe\";\n\nconst MEDIA_POSITIONS: Record<MediaPosition, string> = {\n center: \"center center\",\n top: \"center top\",\n bottom: \"center bottom\",\n left: \"left center\",\n right: \"right center\",\n};\n\nexport function resolveMediaPosition(value: string): string {\n return MEDIA_POSITIONS[value as MediaPosition] ?? MEDIA_POSITIONS.center;\n}\n\nexport function resolveMediaTreatment(value: string): MediaTreatment {\n return value === \"subtle\" || value === \"text-safe\" ? value : \"cinematic\";\n}\n\nexport interface MediaTreatmentLayer {\n id: \"vignette\" | \"full-wash\" | \"center-scrim\" | \"bottom-scrim\";\n background: string;\n style?: React.CSSProperties;\n}\n\n/** Export-safe contrast recipes. Overlays only: SVG capture cannot rely on CSS filters. */\nexport function getMediaTreatmentLayers(value: string): MediaTreatmentLayer[] {\n const treatment = resolveMediaTreatment(value);\n const vignette: MediaTreatmentLayer = {\n id: \"vignette\",\n background:\n treatment === \"subtle\"\n ? \"radial-gradient(ellipse at center, transparent 45%, rgba(0,0,0,0.28) 100%)\"\n : \"radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,0.55) 80%, rgba(0,0,0,0.75) 100%)\",\n };\n if (treatment === \"subtle\") return [vignette];\n\n const cinematic: MediaTreatmentLayer[] = [\n vignette,\n {\n id: \"center-scrim\",\n background:\n treatment === \"text-safe\"\n ? \"radial-gradient(ellipse 90% 56% at 50% 50%, rgba(0,0,0,0.36) 0%, rgba(0,0,0,0.18) 55%, transparent 84%)\"\n : \"radial-gradient(ellipse 85% 50% at 50% 50%, rgba(0,0,0,0.22) 0%, rgba(0,0,0,0.10) 50%, transparent 80%)\",\n },\n {\n id: \"bottom-scrim\",\n background:\n treatment === \"text-safe\"\n ? \"linear-gradient(to top, rgba(0,0,0,0.68) 0%, transparent 100%)\"\n : \"linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 100%)\",\n style: { top: \"55%\" },\n },\n ];\n\n if (treatment === \"text-safe\") {\n cinematic.splice(1, 0, {\n id: \"full-wash\",\n background: \"rgba(0,0,0,0.30)\",\n });\n }\n return cinematic;\n}\n\nexport function resolveMediaType(\n mediaType: string,\n mediaUrl: string,\n): ResolvedMediaType {\n if (mediaType === \"gradient\") return \"gradient\";\n if (mediaType === \"video\") return \"video\";\n if (mediaType === \"photo\") return \"photo\";\n // \"auto\" — detect from URL extension\n return mediaUrl && isVideoUrl(mediaUrl) ? \"video\" : \"photo\";\n}\n\nexport function getMediaBackgroundProps(variables: Record<string, unknown>) {\n return {\n mediaUrl: String(variables.mediaUrl || \"\"),\n mediaType: String(variables.mediaType || \"auto\"),\n mediaPoster: String(variables.mediaPoster || \"\"),\n mediaPosition: String(variables.mediaPosition || \"center\"),\n mediaTreatment: String(variables.mediaTreatment || \"cinematic\"),\n };\n}\n\nexport interface SceneBackgroundProps {\n style: TemplateStyle;\n progress: number;\n sceneDuration?: number;\n width: number;\n height: number;\n mediaUrl?: string;\n mediaType?: string;\n /** Still image URL shown while the <video> backdrop decodes its first\n * frame. Without it the element renders transparent during the\n * ~50–400ms decode window and the gradient flashes through. */\n mediaPoster?: string;\n /** Cover-crop focal anchor. Keeps the important edge/subject visible. */\n mediaPosition?: string;\n /** Overlay recipe: subtle, cinematic, or stronger text-safe contrast. */\n mediaTreatment?: string;\n /** Background motion effect (drift / pulse / Ken Burns). Applied to the photo/video. */\n backgroundEffect?: string;\n /** Stable seed for the gradient breathing animation. Pass the scene's\n * text content (or any stable string) — it's hashed deterministically. */\n seed?: number | string;\n /** Pause video when preview is paused. Defaults to true (export path). */\n isPlaying?: boolean;\n beatIntensity?: number;\n}\n\nexport const SceneBackground: React.FC<SceneBackgroundProps> = ({\n style,\n progress,\n sceneDuration,\n width: _width, // accepted for symmetry; not currently used in render\n height: _height,\n mediaUrl = \"\",\n mediaType = \"auto\",\n mediaPoster,\n mediaPosition = \"center\",\n mediaTreatment = \"cinematic\",\n backgroundEffect,\n seed,\n isPlaying = true,\n beatIntensity = 0,\n}) => {\n void _width;\n void _height;\n const resolved = resolveMediaType(mediaType, mediaUrl);\n const showMedia = resolved !== \"gradient\" && !!mediaUrl;\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const resolvedTreatment = resolveMediaTreatment(mediaTreatment);\n const treatmentLayers = getMediaTreatmentLayers(resolvedTreatment);\n\n const gradSeed =\n typeof seed === \"number\"\n ? seed\n : typeof seed === \"string\"\n ? seed.split(\"\").reduce((acc, c) => acc + c.charCodeAt(0), 0)\n : 0;\n\n const bgTransform = getBackgroundTransform(\n backgroundEffect,\n progress,\n beatIntensity,\n );\n\n // Video playback control — same pause/seek logic bg-media used pre-extract.\n const videoRef = useRef<HTMLVideoElement>(null);\n const lastProgress = useRef(progress);\n const videoStarted = useRef(false);\n\n useEffect(() => {\n const vid = videoRef.current;\n if (!vid) return;\n if (!isPlaying) {\n vid.pause();\n videoStarted.current = false;\n return;\n }\n const progressChanged = Math.abs(progress - lastProgress.current) > 0.001;\n lastProgress.current = progress;\n if (progressChanged && !videoStarted.current) {\n vid.playbackRate = 1;\n vid.currentTime = 0;\n vid.play().catch(() => {});\n videoStarted.current = true;\n } else if (!progressChanged && videoStarted.current) {\n vid.pause();\n videoStarted.current = false;\n }\n }, [progress, isPlaying]);\n\n // Release the decoder on unmount. Without this, iOS Safari keeps the\n // video's decoder buffer alive after the React node is gone — each\n // scene transition (or play/pause/play cycle that remounts the active\n // scene) leaks one decoder, eventually crossing the renderer's memory\n // ceiling and triggering \"A problem repeatedly occurred.\" Same recipe\n // as #409's CanvasPreview preload cleanup: pause → clear src → load().\n // Capture the ref at mount-time so the cleanup uses the same node we\n // mounted (the ref's .current is stale by unmount).\n useEffect(() => {\n const vid = videoRef.current;\n return () => {\n if (!vid) return;\n vid.pause();\n vid.removeAttribute(\"src\");\n vid.load();\n };\n }, []);\n\n return (\n <>\n <BrandGradientOverlay\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n seed={gradSeed}\n />\n\n {showMedia &&\n (resolved === \"video\" ? (\n <video\n ref={videoRef}\n src={mediaUrl}\n // Poster paints during the decode window so the user sees the\n // (still) first frame instead of a transparent <video> letting\n // the brand gradient show through. Pexels returns a thumbnail\n // image alongside each video; fillPexelsUrls stores it in\n // `variables.mediaPoster`. Layered defense alongside preload=\"auto\"\n // below: on desktop the byte preloader makes decode fast, on\n // mobile (where the preloader skips video pre-mounting to dodge\n // the iOS Safari memory crash) the poster is the primary shield.\n poster={mediaPoster || undefined}\n muted\n loop\n playsInline\n // preload=\"auto\" — without it, browsers default to \"metadata\":\n // they only load the container/dimensions, not the byte stream\n // needed to decode frames. The element then renders transparent\n // until the first decoded frame arrives, letting the brand\n // gradient flash through whenever a scene mid-playback transitions\n // to a media backdrop. The parent preloader caches the bytes, but\n // decoder state is per-element, so the active mount still has to\n // decode the first frame; \"auto\" kicks that work off the instant\n // the element mounts.\n preload=\"auto\"\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: resolvedPosition,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n }}\n />\n ) : (\n <div\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n backgroundImage: `url(${mediaUrl})`,\n backgroundSize: \"cover\",\n backgroundPosition: resolvedPosition,\n }}\n />\n ))}\n\n {showMedia &&\n treatmentLayers.map((layer) => (\n <div\n key={layer.id}\n data-media-treatment={resolvedTreatment}\n data-media-overlay={layer.id}\n style={{\n position: \"absolute\",\n inset: 0,\n background: layer.background,\n pointerEvents: \"none\",\n ...layer.style,\n }}\n />\n ))}\n </>\n );\n};\n"
47
+ },
48
+ {
49
+ "path": "src/visual-system/scene-templates/types.ts",
50
+ "type": "registry:lib",
51
+ "target": "vanillasky/scene-templates/types.ts",
52
+ "content": "/**\n * Scene template types.\n *\n * A template is a reusable React component that defines how a scene looks.\n * It declares the variables an editor or model may provide\n * and receives universal settings as props.\n *\n * Templates are searchable by AI via description, category, jobs, register,\n * and useWhen guidance.\n * The variable schema enables any LLM to fill in template variables via JSON.\n */\n\nimport type { ResolvedTokens } from \"../theme\";\nimport type { SafeZone, TemplateStyle } from \"../template-context\";\n\n/**\n * Props passed to every scene template component.\n *\n * All animation must be driven by `progress` (0→1). No CSS animations,\n * no Framer Motion, no requestAnimationFrame. Use interpolate/spring\n * from animation-utils.ts.\n *\n * Scale factor: use `Math.min(width, height) / 1080` — normalizes to\n * the short edge so visuals are consistent across portrait and landscape.\n */\nexport interface SceneTemplateProps {\n variables: Record<string, unknown>;\n style: TemplateStyle;\n /** 0→1 through the scene's duration */\n progress: number;\n /** Presentation clock. Active templates receive the same complete 0→1 timeline as progress. */\n motionProgress?: number;\n /** 0→1 beat pulse intensity */\n beatIntensity: number;\n /** 1080 (portrait) or 1920 (landscape) */\n width: number;\n /** 1920 (portrait) or 1080 (landscape) */\n height: number;\n /** Video-level default text effect (for templates that opt in via usesGlobalTextEffect) */\n textArchetype?: string;\n /** How text leaves the scene (fade / shrink / pop / blur-scale). Falls back to a sensible default per textArchetype when undefined. */\n /** Video-level default background effect (for templates that opt in via usesGlobalBackgroundEffect) */\n backgroundEffect?: string;\n /** Platform-aware safe zone insets in pixels — use for text placement */\n safeZone: SafeZone;\n /** Scene duration in seconds — use for time-based (not progress-based) animations */\n sceneDuration?: number;\n /**\n * Brand tokens already resolved from `style`. Built-in templates import\n * resolveTokens directly; an ejected `custom_*` scene can't import anything,\n * so without this it has no way to reach the same values and ends up\n * hardcoding white, black and shadows — the body then looks generic next to\n * a frame that IS using the brand.\n */\n tokens?: ResolvedTokens;\n /**\n * True when the preview player is actively advancing progress; false when paused.\n * Templates that play HTML5 <video> elements should pause them when this is false.\n * Undefined (export capture path) is treated as true.\n */\n isPlaying?: boolean;\n}\n"
53
+ }
54
+ ],
55
+ "meta": {
56
+ "vanillasky": {
57
+ "layer": "template",
58
+ "tier": "free",
59
+ "register": "card-led",
60
+ "jobs": [
61
+ "claim",
62
+ "payoff"
63
+ ],
64
+ "useWhen": "A direct one-to-one thank-you, invitation, promise, or concise launch note is the point.",
65
+ "textCanvas": "open",
66
+ "minDuration": 2.5,
67
+ "preferredDuration": 4,
68
+ "schema": {
69
+ "type": "object",
70
+ "properties": {
71
+ "message": {
72
+ "type": "string",
73
+ "title": "Message",
74
+ "description": "The brand-voiced message rendered as a single iMessage-style outgoing bubble (right side, cream) over the media background. 4-12 words reads best — short messages produce a tight bubble; longer ones wrap inside the 75% width cap. Examples: 'We built this for you.', 'Thanks for being here.', 'See what's new this week.'",
75
+ "default": "We built this for you."
76
+ },
77
+ "mediaUrl": {
78
+ "type": "string",
79
+ "title": "Background media",
80
+ "description": "Optional photo or video URL behind this scene. When set, replaces the brand gradient.",
81
+ "format": "uri",
82
+ "default": ""
83
+ },
84
+ "mediaKeyword": {
85
+ "type": "string",
86
+ "title": "Background search keyword",
87
+ "description": "2-4 word English term for Pexels stock-footage search (auto-fills mediaUrl).",
88
+ "format": "stock-media-keyword",
89
+ "default": ""
90
+ },
91
+ "mediaType": {
92
+ "type": "string",
93
+ "title": "Background media type",
94
+ "description": "auto detects photo/video from URL. 'gradient' is a deliberate mode — atmospheric brand-color scene with no stock footage.",
95
+ "enum": [
96
+ "auto",
97
+ "photo",
98
+ "video",
99
+ "gradient"
100
+ ],
101
+ "default": "auto"
102
+ },
103
+ "mediaPoster": {
104
+ "type": "string",
105
+ "title": "Background poster image",
106
+ "description": "Still image URL shown while a video backdrop is decoding its first frame. Auto-filled from Pexels' thumbnail when fillPexelsUrls sets a video mediaUrl. Hides the gradient flash that would otherwise appear in the ~50–400ms gap between a <video> mounting and decoding its first frame.",
107
+ "format": "uri",
108
+ "default": ""
109
+ },
110
+ "mediaPosition": {
111
+ "type": "string",
112
+ "title": "Background focal position",
113
+ "description": "Controls which part of a photo or video stays visible when cover-cropped. Pick the subject's side or vertical anchor after inspecting the frame.",
114
+ "enum": [
115
+ "center",
116
+ "top",
117
+ "bottom",
118
+ "left",
119
+ "right"
120
+ ],
121
+ "default": "center"
122
+ },
123
+ "mediaTreatment": {
124
+ "type": "string",
125
+ "title": "Background contrast treatment",
126
+ "description": "subtle preserves a visual hero; cinematic adds balanced contrast; text-safe adds a stronger wash for copy-heavy scenes.",
127
+ "enum": [
128
+ "subtle",
129
+ "cinematic",
130
+ "text-safe"
131
+ ],
132
+ "default": "cinematic"
133
+ }
134
+ },
135
+ "required": [
136
+ "message"
137
+ ],
138
+ "additionalProperties": false,
139
+ "x-vanillasky": {
140
+ "allowsStockMedia": true
141
+ }
142
+ },
143
+ "label": "Brand message",
144
+ "description": "A single iMessage-style outgoing bubble floats over media or a brand gradient.",
145
+ "family": "Social & messaging",
146
+ "avoidWhen": "The scene needs explanation, structured proof, a conversation, or a generic filler line.",
147
+ "usesGlobalTextEffect": false,
148
+ "usesGlobalTransition": false,
149
+ "usesGlobalBackgroundEffect": true,
150
+ "timing": {
151
+ "contentFields": [
152
+ "message"
153
+ ],
154
+ "contentUnit": "words"
155
+ }
156
+ }
157
+ }
158
+ }