@vanillaskyai/video 0.8.8 → 0.9.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.
- package/CHANGELOG.md +30 -0
- package/PUBLIC-API.md +5 -8
- package/dist/{builtin-server-CUYPIDE7.js → builtin-server-HW6HGXCZ.js} +2 -2
- package/dist/{catalog-types-BIhSpOWK.d.ts → catalog-types-WTbLP6Jh.d.ts} +5 -1
- package/dist/chapter-title-YRLIQY3V.js +7 -0
- package/dist/check-runtime.js +6 -15
- package/dist/{chunk-2USVB3CY.js → chunk-3ZTEFJIR.js} +285 -178
- package/dist/chunk-4YM2M62S.js +13 -0
- package/dist/chunk-77VY4O7A.js +390 -0
- package/dist/{chunk-MMUXVA47.js → chunk-7M56IUUX.js} +9 -8
- package/dist/chunk-7NZOMTAL.js +63 -0
- package/dist/{chunk-2DOLF4QN.js → chunk-AUPC6MDK.js} +12 -14
- package/dist/{chunk-352ZZCET.js → chunk-EGVQODKU.js} +2 -2
- package/dist/chunk-EWWCQFTI.js +18 -0
- package/dist/{chunk-MXZSDGZQ.js → chunk-G7IZBBWF.js} +14 -9
- package/dist/{chunk-KT3M7KDL.js → chunk-HKUI5BTB.js} +3 -3
- package/dist/{chunk-LMG3GHLL.js → chunk-IQMYK5DX.js} +29 -3
- package/dist/chunk-LH4BPALA.js +564 -0
- package/dist/{chunk-OWBT45EN.js → chunk-MQILEEXC.js} +1 -1
- package/dist/chunk-MXEYPK7M.js +552 -0
- package/dist/{chunk-AITKH6QT.js → chunk-OR52UMSF.js} +13 -39
- package/dist/{chunk-5SLENAJW.js → chunk-PAJ44TFW.js} +1 -2
- package/dist/{chunk-L7QGGKJ2.js → chunk-PDQFQIQW.js} +1 -1
- package/dist/{chunk-WJ6FLKNC.js → chunk-R3XAOMKP.js} +14 -0
- package/dist/{chunk-2OKB27TG.js → chunk-RXTN2CW6.js} +2 -2
- package/dist/{chunk-XWWLKRNU.js → chunk-TVIU23OM.js} +38 -49
- package/dist/{chunk-224QNWRA.js → chunk-UDIODEDK.js} +1 -5
- package/dist/{chunk-3VRMKFM7.js → chunk-ZDJNFA52.js} +11 -24
- package/dist/cinema-media-YQHSIFDA.js +11 -0
- package/dist/cli.js +18 -9
- package/dist/comparison-RZMMHZR2.js +27 -0
- package/dist/compose-video-TBFTONUC.js +12 -0
- package/dist/editorial-timeline-OZX7OS6G.js +36 -0
- package/dist/{events-BQ6z264n.d.ts → events-B4YCc4vc.d.ts} +1 -1
- package/dist/focus-cards-TJ72BT7U.js +20 -0
- package/dist/index.d.ts +4 -6
- package/dist/index.js +3 -7
- package/dist/key-figure-GYENXKH3.js +20 -0
- package/dist/{kit-tQnCPMOM.d.ts → kit-DrRpdn0p.d.ts} +2 -2
- package/dist/mobile-message-UZUPIKHD.js +49 -0
- package/dist/{preload-media-LJXWKTWG.js → preload-media-CFTZIMZL.js} +1 -1
- package/dist/quote-EW5HQA6H.js +23 -0
- package/dist/react.d.ts +21 -11
- package/dist/react.js +590 -833
- package/dist/scene-video-backdrop-PCTARAAJ.js +7 -0
- package/dist/server.d.ts +7 -8
- package/dist/server.js +91 -74
- package/dist/{system-prompt-RRXIWDDD.js → system-prompt-SYRT5DWM.js} +1 -1
- package/dist/template-catalog.d.ts +257 -16
- package/dist/template-catalog.js +1 -1
- package/dist/templates.d.ts +5 -5
- package/dist/test.d.ts +3 -3
- package/dist/test.js +18 -18
- package/dist/text-stream-LD364KBI.js +9 -0
- package/dist/{types-DWFO6qca.d.ts → types-CdBmNTGD.d.ts} +1 -1
- package/dist/{types-CEh9eUFu.d.ts → types-DdZw4GRQ.d.ts} +17 -48
- package/dist/validate-4OUD2CLX.js +10 -0
- package/docs/architecture.md +1 -1
- package/docs/concepts.md +1 -1
- package/docs/custom-templates.md +13 -28
- package/docs/customization.md +11 -59
- package/docs/getting-started.md +2 -2
- package/docs/immersive-interface.md +3 -4
- package/docs/maintainers/cinematic-migration.md +34 -0
- package/docs/media-and-audio.md +14 -14
- package/docs/motion-and-effects.md +6 -14
- package/docs/persistence.md +3 -3
- package/docs/production.md +11 -9
- package/docs/provider-integration.md +9 -16
- package/docs/reference/design-system.html +1 -1
- package/docs/reference/protocol.md +14 -4
- package/docs/testing.md +1 -1
- package/package.json +6 -3
- package/registry/items/backgrounds.json +1 -1
- package/registry/items/chapterTitle.json +80 -0
- package/registry/items/cinemaMedia.json +136 -0
- package/registry/items/comparison.json +101 -0
- package/registry/items/editorialTimeline.json +105 -0
- package/registry/items/focusCards.json +90 -0
- package/registry/items/keyFigure.json +97 -0
- package/registry/items/mobileMessage.json +153 -0
- package/registry/items/quote.json +94 -0
- package/registry/items/template-context.json +1 -1
- package/registry/items/theme.json +1 -1
- package/starters/video-chat/README.md +24 -26
- package/starters/video-chat/package.json +1 -1
- package/starters/video-chat/providers/video.ts +6 -5
- package/starters/video-chat/stock.ts +35 -151
- package/styles/fonts/OFL.txt +93 -0
- package/styles/fonts/manifest.json +66 -0
- package/styles/fonts/roboto-v51-cyrillic-ext.woff2 +0 -0
- package/styles/fonts/roboto-v51-cyrillic.woff2 +0 -0
- package/styles/fonts/roboto-v51-greek-ext.woff2 +0 -0
- package/styles/fonts/roboto-v51-greek.woff2 +0 -0
- package/styles/fonts/roboto-v51-latin-ext.woff2 +0 -0
- package/styles/fonts/roboto-v51-latin.woff2 +0 -0
- package/styles/fonts/roboto-v51-math.woff2 +0 -0
- package/styles/fonts/roboto-v51-symbols.woff2 +0 -0
- package/styles/fonts/roboto-v51-vietnamese.woff2 +0 -0
- package/styles/fonts/roboto.css +91 -0
- package/styles/video-chat.css +30 -22
- package/dist/bg-confetti-HITCGLGD.js +0 -89
- package/dist/bg-emoji-VU4UZYZN.js +0 -150
- package/dist/bg-media-L34PDQXJ.js +0 -106
- package/dist/brand-message-FUJ4STFO.js +0 -256
- package/dist/chart-bar-EGPHJATL.js +0 -310
- package/dist/chart-counter-5FFWQNZO.js +0 -306
- package/dist/chart-progress-ring-JMXQLMLC.js +0 -302
- package/dist/chunk-4UNKZEAT.js +0 -3575
- package/dist/chunk-5CDAM24P.js +0 -31
- package/dist/chunk-AOWSXU2K.js +0 -18
- package/dist/chunk-CJ7F2CE6.js +0 -104
- package/dist/chunk-FRN6WKHA.js +0 -185
- package/dist/chunk-G5JPHBX7.js +0 -336
- package/dist/chunk-L5BTTCZV.js +0 -97
- package/dist/chunk-P22HHFDK.js +0 -43
- package/dist/chunk-PP3MCPN4.js +0 -1998
- package/dist/chunk-QAAO7OAY.js +0 -19
- package/dist/chunk-S7HFLYNN.js +0 -35
- package/dist/chunk-W3VDIBGR.js +0 -217
- package/dist/chunk-XGRA2MUV.js +0 -242
- package/dist/chunk-XTOXFMLM.js +0 -105
- package/dist/chunk-YJJC4N4D.js +0 -761
- package/dist/compose-video-2NFH4DZY.js +0 -13
- package/dist/cta-logo-YR7LYUR5.js +0 -212
- package/dist/cta-media-DQJBKUO2.js +0 -221
- package/dist/incoming-call-54KQ7O5A.js +0 -323
- package/dist/infographic-before-after-ROS52GOW.js +0 -460
- package/dist/infographic-feature-list-E7MGUDAN.js +0 -272
- package/dist/infographic-problem-solution-3S6NGO5N.js +0 -343
- package/dist/infographic-stat-row-SYB7AB7H.js +0 -247
- package/dist/infographic-steps-KCE6JWE2.js +0 -406
- package/dist/prompt-input-MHX4O42G.js +0 -239
- package/dist/reaction-SLEGR3BE.js +0 -122
- package/dist/scene-video-backdrop-XFPX4O3I.js +0 -10
- package/dist/showcase-code-2HLAVKBM.js +0 -357
- package/dist/showcase-phone-Q4HEO4LH.js +0 -278
- package/dist/showcase-terminal-DL45LUFG.js +0 -312
- package/dist/showcase-web-2QZO73FZ.js +0 -396
- package/dist/social-conversation-75SD2637.js +0 -695
- package/dist/social-milestone-GEFUQSBF.js +0 -296
- package/dist/social-notification-FJ5VHVLK.js +0 -247
- package/dist/social-review-stack-KXQCKFRP.js +0 -242
- package/dist/social-testimonial-JZ7CHC7S.js +0 -287
- package/dist/social-tweet-UCDXM25F.js +0 -298
- package/dist/text-stream-54LXABJW.js +0 -10
- package/dist/validate-T7GBU2YF.js +0 -8
- package/registry/items/barChart.json +0 -245
- package/registry/items/beforeAfter.json +0 -180
- package/registry/items/bigNumber.json +0 -232
- package/registry/items/brandMessage.json +0 -164
- package/registry/items/cardList.json +0 -214
- package/registry/items/chatMessenger.json +0 -151
- package/registry/items/chatWhatsapp.json +0 -151
- package/registry/items/codeEditor.json +0 -191
- package/registry/items/confetti.json +0 -107
- package/registry/items/ctaLogo.json +0 -167
- package/registry/items/ctaMedia.json +0 -200
- package/registry/items/emojiBurst.json +0 -101
- package/registry/items/incomingCall.json +0 -177
- package/registry/items/media.json +0 -187
- package/registry/items/milestone.json +0 -212
- package/registry/items/notification.json +0 -189
- package/registry/items/phoneMockup.json +0 -261
- package/registry/items/problemSolution.json +0 -193
- package/registry/items/progressRing.json +0 -206
- package/registry/items/promptInput.json +0 -155
- package/registry/items/reaction.json +0 -176
- package/registry/items/reviewStack.json +0 -231
- package/registry/items/steps.json +0 -215
- package/registry/items/terminal.json +0 -219
- package/registry/items/testimonial.json +0 -171
- package/registry/items/tripleStats.json +0 -235
- package/registry/items/tweet.json +0 -210
- package/registry/items/webMockup.json +0 -280
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
-
"name": "milestone",
|
|
4
|
-
"type": "registry:block",
|
|
5
|
-
"title": "Milestone",
|
|
6
|
-
"description": "A large count-up achievement with an optional celebratory badge.",
|
|
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/social-milestone.tsx",
|
|
20
|
-
"type": "registry:component",
|
|
21
|
-
"target": "vanillasky/scene-templates/social-milestone.tsx",
|
|
22
|
-
"content": "/**\n * social-milestone — follower/subscriber count rolling up to a milestone with celebration.\n *\n * Converted from Remotion FollowerMilestone. Counter rolls up to target number,\n * glow intensifies, then confetti burst + celebration badge pop on hit.\n *\n * Block structure (docs/blocks.md):\n * background — SceneBackground (brand gradient / Pexels media + scrims)\n * hero — MilestoneBadge primitive (rolling number + glow + confetti\n * + celebration pill)\n * caption — the uppercase label inside MilestoneBadge (no TemplateText)\n */\nimport type { SceneTemplateProps } from \"./types\";\nimport { resolveTokens } from \"../theme\";\nimport { SceneBackground, getMediaBackgroundProps } from \"./scene-background\";\nimport { stripPipe } from \"../typography\";\nimport { MilestoneBadge } from \"../primitives/social/MilestoneBadge\";\n\nexport const SocialMilestoneTemplate: React.FC<SceneTemplateProps> = ({\n variables,\n style,\n progress,\n beatIntensity,\n width,\n height,\n sceneDuration,\n isPlaying = true,\n backgroundEffect,\n}) => {\n // Use the canonical semantic accent token.\n const { primary, secondary, foreground, surfaceElevated, font } = resolveTokens(style);\n\n const label = stripPipe(String(variables.label || \"Followers\"));\n const gradSeed = label.split(\"\").reduce((acc: number, c: string) => acc + c.charCodeAt(0), 0);\n const targetNumber = Number(variables.targetNumber) || 10000;\n const rawStart = variables.startNumber != null ? Number(variables.startNumber) : undefined;\n const badgeText = stripPipe(String(variables.badgeText || \"\"));\n const badgeEmoji = String(variables.badgeEmoji || \"🎉\");\n\n // Media-mode legibility: when a Pexels photo/video is behind, MilestoneBadge\n // forces the label to full white and stacks a stronger drop-shadow on\n // number + label so they punch through busy footage. SceneBackground\n // already adds a vignette + bottom scrim. Mirrors bigNumber's treatment.\n const hasMedia = !!String(variables.mediaUrl || \"\").trim() &&\n String(variables.mediaType || \"auto\") !== \"gradient\";\n\n return (\n <div\n style={{\n width,\n height,\n backgroundColor: \"var(--vanillasky-template-surface, #000)\",\n position: \"relative\",\n overflow: \"hidden\",\n fontFamily: font,\n display: \"flex\",\n justifyContent: \"center\",\n alignItems: \"center\",\n }}\n >\n {/* [slot: background] Gradient background — supports Pexels media 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 {/* [slot: hero] Rolling counter + label + confetti + celebration pill —\n shared primitive (owns the milestone timing windows) */}\n <MilestoneBadge\n progress={progress}\n width={width}\n height={height}\n targetNumber={targetNumber}\n label={label}\n startNumber={rawStart}\n badgeText={badgeText}\n badgeEmoji={badgeEmoji}\n accent={primary}\n hasMedia={hasMedia}\n foreground={foreground}\n surfaceElevated={surfaceElevated}\n beatIntensity={beatIntensity}\n confettiBgColor={secondary}\n />\n </div>\n );\n};\n"
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
"path": "src/visual-system/primitives/social/MilestoneBadge.tsx",
|
|
26
|
-
"type": "registry:component",
|
|
27
|
-
"target": "vanillasky/primitives/social/MilestoneBadge.tsx",
|
|
28
|
-
"content": "/**\n * MilestoneBadge — numeric achievement badge with rolling counter, glow,\n * confetti burst, and celebration pill.\n *\n * The\n * template composes this component, so the two can no longer drift.\n * The primitive owns:\n * - the big rolling number with auto-fit font scale\n * - the uppercase label above\n * - the radial glow that ramps up as the count nears target\n * - the confetti burst (via shared ConfettiLayer) that fires on hit\n * - the celebration pill (\"🎉 10K Followers!\") that pops in on hit\n *\n * It does NOT own the SceneBackground gradient/media — that stays in the\n * scene composer so the same badge can render over brand gradient or\n * Pexels footage.\n *\n * Distinct from CountUpNumber: this primitive has badge framing\n * (label + pill + confetti) baked in.\n *\n * Props:\n * - progress — scene progress 0..1\n * - width / height — frame dimensions\n * - targetNumber — milestone to reach\n * - label — uppercase label above the number (e.g. \"Followers\")\n * - prefix / suffix — optional decoration around the number (e.g. \"$\", \"+\", \"K\")\n * - startNumber — counter origin. Defaults to ~98% of target so it\n * always rolls UP visibly.\n * - badgeText — celebration pill text (e.g. \"10K Followers!\")\n * - badgeEmoji — emoji shown in the pill (default 🎉)\n * - accent — brand accent driving the glow color (default \"#00e5a0\")\n * - hasMedia — when true, use the media-safe text treatment\n * (full white label + drop shadows). Defaults to false.\n * - foreground — resolved semantic foreground for gradient mode;\n * media mode uses its deliberate scrim-safe white.\n * - surfaceElevated — opaque semantic surface for the celebration pill.\n * - beatIntensity — optional 0..1 audio reactivity (subtle scale pop)\n * - confettiBgColor — optional confetti hue-filter input (parity with source)\n */\n\nimport * as React from \"react\";\nimport {\n interpolate,\n spring,\n SPRING_SMOOTH,\n SPRING_BOUNCY,\n} from \"../../motion\";\nimport { stripPipe } from \"../../typography\";\nimport { Emoji } from \"../../emoji\";\nimport { renderWithEmoji } from \"../../emoji/emoji-text\";\nimport { ConfettiLayer } from \"../../scene-templates/confetti-layer\";\nimport { accessibleTextColor, withOpacity, MEDIA_TEXT_SHADOW, TOKEN_DEFAULTS } from \"../../theme\";\n\nconst CLAMP = {\n extrapolateLeft: \"clamp\" as const,\n extrapolateRight: \"clamp\" as const,\n};\n\nexport interface MilestoneBadgeProps {\n progress: number;\n width: number;\n height: number;\n targetNumber: number;\n label: string;\n prefix?: string;\n suffix?: string;\n startNumber?: number;\n badgeText?: string;\n badgeEmoji?: string;\n badgeColor?: string;\n accent?: string;\n hasMedia?: boolean;\n /** Resolved semantic foreground for text on the main background. */\n foreground?: string;\n /** Resolved semantic elevated surface for the celebration pill. */\n surfaceElevated?: string;\n beatIntensity?: number;\n confettiBgColor?: string;\n}\n\nexport const MilestoneBadge: React.FC<MilestoneBadgeProps> = ({\n progress,\n width,\n height,\n targetNumber,\n label: rawLabel,\n prefix = \"\",\n suffix = \"\",\n startNumber,\n badgeText: rawBadgeText = \"\",\n badgeEmoji = \"🎉\",\n accent = TOKEN_DEFAULTS.primary,\n hasMedia = false,\n foreground = TOKEN_DEFAULTS.foreground,\n surfaceElevated = TOKEN_DEFAULTS.surfaceElevated,\n beatIntensity = 0,\n confettiBgColor,\n}) => {\n const dim = Math.min(width, height);\n const s = dim / 1080;\n\n const label = stripPipe(rawLabel || \"\");\n const badgeText = stripPipe(rawBadgeText || \"\");\n\n const safeTarget = Number.isFinite(targetNumber) ? targetNumber : 0;\n const safeStart =\n startNumber != null && Number.isFinite(startNumber)\n ? startNumber\n : Math.max(0, Math.round(safeTarget * 0.98));\n\n const mainTextColor = hasMedia ? \"#FFFFFF\" : foreground;\n const pillTextColor = accessibleTextColor(surfaceElevated, foreground);\n const mediaTextShadow = hasMedia ? MEDIA_TEXT_SHADOW : undefined;\n\n // ── Timing (mirror social-milestone source) ────────────────────\n // Counter rolls from progress 0.056 to 0.556\n const followerCount = Math.round(\n interpolate(progress, [0.056, 0.556], [safeStart, safeTarget], CLAMP),\n );\n const hasHitTarget = followerCount >= safeTarget;\n\n const numberScale = spring(\n interpolate(progress, [0, 0.25], [0, 1], CLAMP),\n SPRING_SMOOTH,\n );\n const glowIntensity = interpolate(progress, [0.33, 0.556], [0, 1], CLAMP);\n const labelOpacity = interpolate(progress, [0, 0.167], [0, 1], CLAMP);\n\n const badgeP = hasHitTarget\n ? spring(\n interpolate(progress, [0.556, 0.85], [0, 1], CLAMP),\n SPRING_BOUNCY,\n )\n : 0;\n const badgeScale = interpolate(badgeP, [0, 0.5, 1], [0, 1.2, 1], CLAMP);\n const badgeOpacity = badgeP;\n\n const confettiActive = hasHitTarget;\n const confettiProgress = confettiActive\n ? Math.max(0, (progress - 0.556) / 0.444)\n : 0;\n\n const beatScale = 1 + beatIntensity * 0.02;\n\n const numberDisplay = `${prefix}${followerCount.toLocaleString()}${suffix}`;\n // Auto-scale thresholds match social-milestone (the tuned source of truth).\n const fontSize =\n numberDisplay.length > 9\n ? dim * 0.09\n : numberDisplay.length > 6\n ? dim * 0.12\n : dim * 0.16;\n\n return (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n display: \"flex\",\n justifyContent: \"center\",\n alignItems: \"center\",\n }}\n >\n {/* Confetti — shared 200-particle layer, fires on milestone hit */}\n {confettiActive && (\n <ConfettiLayer\n progress={confettiProgress}\n width={width}\n height={height}\n beatIntensity={beatIntensity}\n bgColor={hasMedia ? undefined : confettiBgColor || accent}\n />\n )}\n\n <div\n style={{\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"center\",\n gap: dim * 0.015,\n transform: `scale(${numberScale * beatScale})`,\n position: \"relative\",\n zIndex: 1,\n width: \"100%\",\n }}\n >\n {/* Label */}\n {label && (\n <div\n style={{\n color: mainTextColor,\n fontSize: dim * 0.032,\n fontWeight: 500,\n textTransform: \"uppercase\",\n letterSpacing: dim * 0.004,\n opacity: labelOpacity,\n textAlign: \"center\",\n ...(mediaTextShadow ? { textShadow: mediaTextShadow } : {}),\n }}\n >\n {label}\n </div>\n )}\n\n {/* Big number */}\n <div\n style={{\n color: mainTextColor,\n fontSize,\n fontWeight: 800,\n letterSpacing: dim * -0.004,\n lineHeight: 1,\n position: \"relative\",\n maxWidth: \"90%\",\n textAlign: \"center\",\n ...(mediaTextShadow ? { textShadow: mediaTextShadow } : {}),\n }}\n >\n {/* Glow behind number */}\n <div\n style={{\n position: \"absolute\",\n top: \"50%\",\n left: \"50%\",\n transform: \"translate(-50%, -50%)\",\n width: dim * 0.5,\n height: dim * 0.2,\n borderRadius: \"50%\",\n background: `radial-gradient(ellipse, ${withOpacity(accent, glowIntensity * 0.2)} 0%, ${withOpacity(accent, glowIntensity * 0.05)} 50%, transparent 70%)`,\n pointerEvents: \"none\",\n }}\n />\n <span style={{ position: \"relative\" }}>{numberDisplay}</span>\n </div>\n\n {/* Celebration badge */}\n {badgeText && (\n <div\n style={{\n position: \"absolute\",\n top: \"100%\",\n left: \"50%\",\n transform: `translateX(-50%) scale(${badgeScale})`,\n opacity: badgeOpacity,\n backgroundColor: surfaceElevated,\n border: \"1px solid rgba(255,255,255,0.15)\",\n borderRadius: 100 * s,\n padding: `${dim * 0.015}px ${dim * 0.037}px`,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: dim * 0.011,\n whiteSpace: \"nowrap\",\n marginTop: dim * 0.02,\n }}\n >\n <Emoji char={badgeEmoji} size={dim * 0.045} verticalAlign=\"middle\" />\n <span\n style={{\n color: pillTextColor,\n fontSize: dim * 0.036,\n fontWeight: 700,\n letterSpacing: dim * 0.0005,\n }}\n >\n {renderWithEmoji(badgeText, dim * 0.036)}\n </span>\n </div>\n )}\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/emoji/emoji-text.tsx",
|
|
44
|
-
"type": "registry:component",
|
|
45
|
-
"target": "vanillasky/emoji/emoji-text.tsx",
|
|
46
|
-
"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"
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
"path": "src/visual-system/emoji/index.tsx",
|
|
50
|
-
"type": "registry:component",
|
|
51
|
-
"target": "vanillasky/emoji/index.tsx",
|
|
52
|
-
"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"
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
"path": "src/visual-system/scene-templates/media-source.ts",
|
|
56
|
-
"type": "registry:lib",
|
|
57
|
-
"target": "vanillasky/scene-templates/media-source.ts",
|
|
58
|
-
"content": "/**\n * How a scene's backdrop resolves from its variables. Pure, React-free, and\n * deliberately a leaf module: the player's media preloader shares it so the\n * question \"is this scene backed by a photo, a video, or the brand gradient?\"\n * has exactly one answer in the codebase.\n */\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\n/** Apple mobile browsers share WebKit's conservative video-decoder ceiling. */\nexport function limitsConcurrentVideoDecoders(): boolean {\n if (typeof navigator === \"undefined\") return false;\n return /iPad|iPhone|iPod/.test(navigator.userAgent) ||\n (navigator.platform === \"MacIntel\" && navigator.maxTouchPoints > 1);\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\n/**\n * True when the scene actually renders a photo or video backdrop — i.e. a\n * mediaUrl is set and the template has not been pinned to the brand gradient.\n * Templates use it to switch their type onto the media legibility recipe.\n */\nexport function hasSceneMedia(variables: Record<string, unknown>): boolean {\n return (\n String(variables.mediaUrl || \"\").trim() !== \"\" &&\n String(variables.mediaType || \"auto\") !== \"gradient\"\n );\n}\n"
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
"path": "src/visual-system/scene-templates/scene-background.tsx",
|
|
62
|
-
"type": "registry:component",
|
|
63
|
-
"target": "vanillasky/scene-templates/scene-background.tsx",
|
|
64
|
-
"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. Legibility is then split between two instruments:\n * eased scrims shaped to where the template's copy sits (`textAnchor`),\n * and a per-glyph halo on the type itself (MEDIA_TEXT_SHADOW). Neither\n * alone can hold white type over a blown-out highlight without flattening\n * the picture; together they do it at roughly half the darkening.\n * - mediaType=\"gradient\" deliberately ignores mediaUrl and renders only\n * the brand gradient. First-class atmospheric mode.\n * - When mediaUrl is empty, 404s, is blocked, or Pexels search returned\n * nothing, the gradient shows through cleanly and no scrim is painted —\n * a scrim over a bare gradient is just a muddy gradient. Enforced, not\n * assumed: the media has to load before anything darkens for it.\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, useState } from \"react\";\nimport {\n hasSceneMedia,\n resolveMediaType,\n type ResolvedMediaType,\n} from \"./media-source\";\nimport type { TemplateStyle } from \"../template-context\";\nimport { BrandGradientOverlay } from \"../backgrounds\";\nimport { getBackgroundTransform } from \"../backgrounds\";\nimport { useExternalVideoBackdrop } from \"./external-video-backdrop\";\nimport { resolveMediaPosition, type MediaPosition } from \"./media-position\";\nimport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\n\nexport { hasSceneMedia, resolveMediaType };\nexport { resolveMediaPosition } from \"./media-position\";\nexport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\nexport type { ResolvedMediaType };\n\nexport type { MediaPosition };\nexport type MediaTreatment = \"none\" | \"subtle\" | \"cinematic\" | \"text-safe\";\n\nexport function resolveMediaTreatment(value: string): MediaTreatment {\n return value === \"none\" || value === \"subtle\" || value === \"text-safe\" ? value : \"cinematic\";\n}\n\nexport interface MediaTreatmentLayer {\n id: \"vignette\" | \"center-scrim\" | \"bottom-scrim\";\n background: string;\n style?: React.CSSProperties;\n}\n\n/**\n * Where the template puts its type. The scrim is shaped to the copy, not to\n * the frame: darkening picture the type never touches costs contrast in the\n * photo and buys no legibility. \"full\" is the conservative default for\n * templates that have not declared an anchor.\n */\nexport type MediaTextAnchor = \"center\" | \"bottom\" | \"full\";\n\n/**\n * Smoothstep-sampled alpha stops between `start`% and `end`% of the gradient\n * box, held at full strength before `start` and after `end`.\n *\n * A two-stop `rgba(0,0,0,a) → transparent` scrim ramps alpha linearly, so it\n * ends with a constant slope. Lateral inhibition in the eye amplifies that\n * slope discontinuity into a visible band — the grey bar cutting across the\n * frame that makes an overlay read as an overlay. Smoothstep flattens the\n * curve at both ends, so the scrim holds where the type sits and then leaves\n * without an edge: the same peak coverage over the copy, noticeably less of\n * the picture spent getting there.\n */\nconst SCRIM_STOP_COUNT = 7;\n\nfunction smoothstep(t: number): number {\n return t * t * (3 - 2 * t);\n}\n\nfunction easedStops(\n peakAlpha: number,\n start: number,\n end: number,\n direction: \"fade-out\" | \"fade-in\",\n): string {\n const alphaAt = (t: number): string => {\n const eased = direction === \"fade-out\" ? 1 - smoothstep(t) : smoothstep(t);\n return `rgba(0,0,0,${Number((peakAlpha * eased).toFixed(3))})`;\n };\n const stops: string[] = [];\n if (start > 0) stops.push(`${alphaAt(0)} 0%`);\n for (let i = 0; i < SCRIM_STOP_COUNT; i += 1) {\n const t = i / (SCRIM_STOP_COUNT - 1);\n const position = Number((start + (end - start) * t).toFixed(2));\n stops.push(`${alphaAt(t)} ${position}%`);\n }\n if (end < 100) stops.push(`${alphaAt(1)} 100%`);\n return stops.join(\", \");\n}\n\n/**\n * Export-safe contrast recipes. Overlays only: SVG capture cannot rely on CSS\n * filters, so a blur-behind-text plate is off the table.\n *\n * The scrims deliberately stop short of solving legibility on their own. A\n * uniform darkening strong enough to carry white type over a blown-out sky\n * needs roughly 0.8 alpha — at that point the photo is a texture, not a\n * picture. The cheaper half of the job belongs to the type: a per-glyph halo\n * (MEDIA_TEXT_SHADOW) buys local contrast exactly where it is needed and\n * costs the image nothing. Scrim for the plate, halo for the glyph.\n */\nexport function getMediaTreatmentLayers(\n value: string,\n anchor: MediaTextAnchor = \"full\",\n): MediaTreatmentLayer[] {\n const treatment = resolveMediaTreatment(value);\n // Nothing over the picture at all. Every other treatment exists to carry\n // type across a photograph; where the scene has no type - a generated clip\n // that is the whole point of the beat - even a vignette is something the\n // viewer did not ask to look through.\n if (treatment === \"none\") return [];\n const vignette: MediaTreatmentLayer = {\n id: \"vignette\",\n background:\n treatment === \"subtle\"\n ? `radial-gradient(ellipse at center, ${easedStops(0.28, 45, 100, \"fade-in\")})`\n : `radial-gradient(ellipse at center, ${easedStops(0.72, 32, 100, \"fade-in\")})`,\n };\n if (treatment === \"subtle\") return [vignette];\n\n const textSafe = treatment === \"text-safe\";\n const layers: MediaTreatmentLayer[] = [vignette];\n\n if (anchor !== \"bottom\") {\n layers.push({\n id: \"center-scrim\",\n background: textSafe\n ? `radial-gradient(ellipse 92% 58% at 50% 50%, ${easedStops(0.46, 34, 90, \"fade-out\")})`\n : `radial-gradient(ellipse 88% 52% at 50% 50%, ${easedStops(0.26, 30, 88, \"fade-out\")})`,\n });\n }\n\n if (anchor !== \"center\") {\n layers.push({\n id: \"bottom-scrim\",\n background: `linear-gradient(to top, ${easedStops(textSafe ? 0.64 : 0.5, 8, 100, \"fade-out\")})`,\n style: { top: \"55%\" },\n });\n }\n\n return layers;\n}\n\n/**\n * Whether the backdrop is actually painting, which is what decides if a scrim\n * is earned. \"pending\" is a browser-only state: static and export renders\n * never run effects and never wait on a network, so they start (and stay)\n * ready and their output is unchanged.\n */\ntype MediaPaintState = \"pending\" | \"ready\" | \"failed\";\n\nfunction initialMediaPaint(\n wantsMedia: boolean,\n resolved: ResolvedMediaType,\n mediaUrl: string,\n mediaPoster: string | undefined,\n): MediaPaintState {\n if (typeof window === \"undefined\") return \"ready\";\n if (!wantsMedia) return \"ready\";\n // A poster paints the video's frame immediately, so the scene is already\n // showing footage even though the stream is still decoding.\n if (resolved === \"video\") return mediaPoster ? \"ready\" : \"pending\";\n if (typeof Image === \"undefined\") return \"ready\";\n // Preloaded or browser-cached media decodes synchronously. Reporting it\n // ready on the first render keeps the common mid-playback case free of a\n // gradient-then-photo flicker.\n const cached = new Image();\n cached.src = mediaUrl;\n return cached.complete && cached.naturalWidth > 0 ? \"ready\" : \"pending\";\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 /** Where this template's copy sits, so the scrim is shaped to the type\n * instead of to the frame. Defaults to \"full\" (scrim both the middle and\n * the lower third) for templates that have not declared an anchor. */\n textAnchor?: MediaTextAnchor;\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 textAnchor = \"full\",\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 wantsMedia = resolved !== \"gradient\" && !!mediaUrl;\n const externalVideoBackdrop = useExternalVideoBackdrop();\n const hasExternalVideoBackdrop = externalVideoBackdrop !== false && resolved === \"video\";\n const externalVideoFailed = externalVideoBackdrop === \"fallback\" && resolved === \"video\";\n const externalVideoReady = externalVideoBackdrop === \"ready\" && resolved === \"video\";\n\n // A scrim exists to hold type against footage. Until the footage is on\n // screen there is nothing to hold it against, so the scrim would just be\n // darkening the brand gradient it was never meant to touch — the scene\n // reads as a muddy, vignetted version of the gradient scenes beside it.\n // That window is not rare: it covers the whole load, and it never ends for\n // a dead URL, a blocked host, or an empty stock search.\n //\n // So the media has to paint before anything darkens for it. Both edges of\n // the swap land on the same commit — scrim and picture appear together,\n // and the fallback is the clean gradient the docs always promised.\n const [mediaPaint, setMediaPaint] = useState<MediaPaintState>(() =>\n initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster),\n );\n useEffect(() => {\n setMediaPaint(initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster));\n // Video reports its own paint through onLoadedData / onError below.\n if (!wantsMedia || resolved !== \"photo\") return;\n if (typeof Image === \"undefined\") return;\n let cancelled = false;\n const probe = new Image();\n probe.onload = () => {\n if (!cancelled) setMediaPaint(\"ready\");\n };\n probe.onerror = () => {\n if (!cancelled) setMediaPaint(\"failed\");\n };\n probe.src = mediaUrl;\n if (probe.complete) setMediaPaint(probe.naturalWidth > 0 ? \"ready\" : \"failed\");\n return () => {\n cancelled = true;\n probe.onload = null;\n probe.onerror = null;\n };\n }, [mediaUrl, mediaPoster, resolved, wantsMedia]);\n\n // The element stays mounted while pending — that is what loads it. Only a\n // confirmed failure takes it back out.\n const showMedia = wantsMedia && mediaPaint !== \"failed\";\n const showTreatment = wantsMedia && mediaPaint === \"ready\";\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const resolvedTreatment = resolveMediaTreatment(mediaTreatment);\n const treatmentLayers = getMediaTreatmentLayers(resolvedTreatment, textAnchor);\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 return (\n <>\n {(!hasExternalVideoBackdrop || externalVideoFailed) && (\n <BrandGradientOverlay\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n seed={gradSeed}\n />\n )}\n\n {showMedia && !hasExternalVideoBackdrop &&\n (resolved === \"video\" ? (\n <SceneVideoBackdrop\n mediaUrl={mediaUrl}\n mediaPoster={mediaPoster}\n mediaPosition={mediaPosition}\n backgroundEffect={backgroundEffect}\n progress={progress}\n beatIntensity={beatIntensity}\n isPlaying={isPlaying}\n onReady={() => setMediaPaint(\"ready\")}\n onError={() => setMediaPaint(\"failed\")}\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 {(hasExternalVideoBackdrop ? externalVideoReady : showTreatment) && !externalVideoFailed &&\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"
|
|
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
|
-
"proof",
|
|
80
|
-
"payoff"
|
|
81
|
-
],
|
|
82
|
-
"useWhen": "An exact grounded achievement count marks users, revenue, years, installs, customers, or adoption.",
|
|
83
|
-
"textCanvas": "open",
|
|
84
|
-
"minDuration": 2.5,
|
|
85
|
-
"preferredDuration": 4,
|
|
86
|
-
"schema": {
|
|
87
|
-
"type": "object",
|
|
88
|
-
"properties": {
|
|
89
|
-
"label": {
|
|
90
|
-
"type": "string",
|
|
91
|
-
"title": "Label",
|
|
92
|
-
"description": "Short label above the number (e.g. Followers, Subscribers, Downloads; 32 characters maximum).",
|
|
93
|
-
"minLength": 1,
|
|
94
|
-
"maxLength": 32,
|
|
95
|
-
"default": "Followers"
|
|
96
|
-
},
|
|
97
|
-
"targetNumber": {
|
|
98
|
-
"type": "number",
|
|
99
|
-
"title": "Target number",
|
|
100
|
-
"description": "The milestone number to reach",
|
|
101
|
-
"default": 10000
|
|
102
|
-
},
|
|
103
|
-
"startNumber": {
|
|
104
|
-
"type": "number",
|
|
105
|
-
"title": "Start number",
|
|
106
|
-
"description": "Number the counter starts rolling from. Defaults to 0.",
|
|
107
|
-
"default": 0
|
|
108
|
-
},
|
|
109
|
-
"badgeText": {
|
|
110
|
-
"type": "string",
|
|
111
|
-
"title": "Badge text",
|
|
112
|
-
"description": "Concise celebration badge text that pops in at the milestone (32 characters maximum).",
|
|
113
|
-
"maxLength": 32,
|
|
114
|
-
"examples": [
|
|
115
|
-
"10K Followers!"
|
|
116
|
-
],
|
|
117
|
-
"default": ""
|
|
118
|
-
},
|
|
119
|
-
"badgeEmoji": {
|
|
120
|
-
"type": "string",
|
|
121
|
-
"title": "Badge emoji",
|
|
122
|
-
"description": "Emoji shown in the celebration badge",
|
|
123
|
-
"format": "emoji",
|
|
124
|
-
"maxLength": 16,
|
|
125
|
-
"default": "🎉"
|
|
126
|
-
},
|
|
127
|
-
"mediaUrl": {
|
|
128
|
-
"type": "string",
|
|
129
|
-
"title": "Background media",
|
|
130
|
-
"description": "Optional photo or video URL behind this scene. When set, replaces the brand gradient.",
|
|
131
|
-
"format": "uri",
|
|
132
|
-
"default": ""
|
|
133
|
-
},
|
|
134
|
-
"mediaKeyword": {
|
|
135
|
-
"type": "string",
|
|
136
|
-
"title": "Background search keyword",
|
|
137
|
-
"description": "2-4 word English term for stock-media search (auto-fills mediaUrl).",
|
|
138
|
-
"format": "stock-media-keyword",
|
|
139
|
-
"default": ""
|
|
140
|
-
},
|
|
141
|
-
"mediaType": {
|
|
142
|
-
"type": "string",
|
|
143
|
-
"title": "Background media type",
|
|
144
|
-
"description": "auto detects photo/video from URL. 'gradient' is a deliberate mode — atmospheric brand-color scene with no stock footage.",
|
|
145
|
-
"enum": [
|
|
146
|
-
"auto",
|
|
147
|
-
"photo",
|
|
148
|
-
"video",
|
|
149
|
-
"gradient"
|
|
150
|
-
],
|
|
151
|
-
"default": "auto"
|
|
152
|
-
},
|
|
153
|
-
"mediaPoster": {
|
|
154
|
-
"type": "string",
|
|
155
|
-
"title": "Background poster image",
|
|
156
|
-
"description": "Still image URL shown while a video backdrop is decoding its first frame. A media resolver can supply a poster alongside 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.",
|
|
157
|
-
"format": "uri",
|
|
158
|
-
"default": ""
|
|
159
|
-
},
|
|
160
|
-
"mediaPosition": {
|
|
161
|
-
"type": "string",
|
|
162
|
-
"title": "Background focal position",
|
|
163
|
-
"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.",
|
|
164
|
-
"enum": [
|
|
165
|
-
"center",
|
|
166
|
-
"top",
|
|
167
|
-
"bottom",
|
|
168
|
-
"left",
|
|
169
|
-
"right"
|
|
170
|
-
],
|
|
171
|
-
"default": "center"
|
|
172
|
-
},
|
|
173
|
-
"mediaTreatment": {
|
|
174
|
-
"type": "string",
|
|
175
|
-
"title": "Background contrast treatment",
|
|
176
|
-
"description": "none leaves the picture untouched, for a scene carrying no type; subtle preserves a visual hero; cinematic adds balanced contrast; text-safe adds a stronger wash for copy-heavy scenes.",
|
|
177
|
-
"enum": [
|
|
178
|
-
"none",
|
|
179
|
-
"subtle",
|
|
180
|
-
"cinematic",
|
|
181
|
-
"text-safe"
|
|
182
|
-
],
|
|
183
|
-
"default": "cinematic"
|
|
184
|
-
}
|
|
185
|
-
},
|
|
186
|
-
"required": [
|
|
187
|
-
"label",
|
|
188
|
-
"targetNumber"
|
|
189
|
-
],
|
|
190
|
-
"additionalProperties": false,
|
|
191
|
-
"x-vanillasky": {
|
|
192
|
-
"requiresStat": true,
|
|
193
|
-
"allowsStockMedia": true
|
|
194
|
-
}
|
|
195
|
-
},
|
|
196
|
-
"label": "Milestone",
|
|
197
|
-
"description": "A large count-up achievement with an optional celebratory badge.",
|
|
198
|
-
"family": "Data & metrics",
|
|
199
|
-
"avoidWhen": "The value is a release number, price, unbounded percentage, or not an achievement count.",
|
|
200
|
-
"usesGlobalTextEffect": false,
|
|
201
|
-
"usesGlobalTransition": false,
|
|
202
|
-
"usesGlobalBackgroundEffect": false,
|
|
203
|
-
"timing": {
|
|
204
|
-
"contentFields": [
|
|
205
|
-
"label",
|
|
206
|
-
"badgeText"
|
|
207
|
-
],
|
|
208
|
-
"contentUnit": "words"
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
}
|
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
-
"name": "notification",
|
|
4
|
-
"type": "registry:block",
|
|
5
|
-
"title": "Notification",
|
|
6
|
-
"description": "A compact app notification card appears over a branded background.",
|
|
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/social-notification.tsx",
|
|
20
|
-
"type": "registry:component",
|
|
21
|
-
"target": "vanillasky/scene-templates/social-notification.tsx",
|
|
22
|
-
"content": "/**\n * social-notification — iOS-style notification card on a media background.\n *\n * Card visuals + motion live in the NotificationCard primitive\n * (src/visual-system/primitives/social/NotificationCard.tsx). This template wires the\n * scene background + variable schema + primitive together.\n */\n\nimport * as React from \"react\";\nimport type { SceneTemplateProps } from \"./types\";\nimport { SceneBackground, getMediaBackgroundProps } from \"./scene-background\";\nimport { NotificationCard } from \"../primitives/social/NotificationCard\";\nimport { resolveTokens } from \"./tokens\";\n\nexport const SocialNotificationTemplate: React.FC<SceneTemplateProps> = ({\n variables,\n style,\n progress,\n motionProgress = progress,\n beatIntensity,\n width,\n height,\n sceneDuration,\n isPlaying = true,\n backgroundEffect,\n}) => {\n const { font } = resolveTokens(style);\n const message = String(variables.message || \"\");\n const gradSeed = message.split(\"\").reduce((acc, c) => acc + c.charCodeAt(0), 0);\n\n return (\n <div\n style={{\n width,\n height,\n backgroundColor: \"var(--vanillasky-template-surface, #000)\",\n position: \"relative\",\n overflow: \"hidden\",\n fontFamily: font,\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={gradSeed}\n isPlaying={isPlaying}\n beatIntensity={beatIntensity}\n />\n\n {/* [slot: hero] Notification card — NotificationCard primitive\n (the message is the content; no separate caption slot) */}\n <NotificationCard\n progress={progress}\n motionProgress={motionProgress}\n appName={String(variables.appName || \"Reminder\")}\n appIcon={String(variables.appIcon || \"🔔\")}\n message={message}\n width={width}\n height={height}\n beatIntensity={beatIntensity}\n />\n </div>\n );\n};\n"
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
"path": "src/visual-system/primitives/social/NotificationCard.tsx",
|
|
26
|
-
"type": "registry:component",
|
|
27
|
-
"target": "vanillasky/primitives/social/NotificationCard.tsx",
|
|
28
|
-
"content": "/**\n * NotificationCard\n *\n * iOS-style glassmorphic notification card. Header (icon + app name + \"now\"),\n * then word-by-word message reveal. Card scales/lifts in via spring.\n *\n * social-notification.tsx is refactored to consume this primitive; both\n * paths render visually identical output.\n */\n\nimport * as React from \"react\";\nimport { interpolate, spring, SPRING_SMOOTH } from \"../../motion\";\nimport { stripPipe } from \"../../typography\";\nimport { Emoji } from \"../../emoji\";\nimport { renderWithEmoji } from \"../../emoji/emoji-text\";\n\nconst CLAMP = { extrapolateLeft: \"clamp\" as const, extrapolateRight: \"clamp\" as const };\n\n// ─── Typed component (direct use from templates) ────────────────\n\nexport interface NotificationCardProps {\n progress: number;\n /** Transition-safe presentation clock; semantic scene time stays on progress. */\n motionProgress?: number;\n appName: string;\n /** Single emoji icon. Default 🔔. */\n appIcon?: string;\n message: string;\n width: number;\n height: number;\n beatIntensity?: number;\n}\n\nexport const NotificationCard: React.FC<NotificationCardProps> = ({\n progress,\n motionProgress = progress,\n appName: rawAppName,\n appIcon: rawAppIcon,\n message: rawMessage,\n width,\n height,\n beatIntensity = 0,\n}) => {\n const dim = Math.min(width, height);\n const appName = stripPipe(rawAppName);\n const appIcon = (rawAppIcon || \"🔔\").trim() || \"🔔\";\n const message = rawMessage;\n const presentationProgress = Math.max(progress, motionProgress);\n\n const cardSpring = spring(interpolate(presentationProgress, [0.05, 0.20], [0, 1], CLAMP), SPRING_SMOOTH);\n const cardOpacity = interpolate(presentationProgress, [0.05, 0.15], [0, 1], CLAMP);\n const cardScale = interpolate(cardSpring, [0, 1], [0.95, 1]);\n const cardLift = (1 - cardSpring) * 12;\n\n const headerOpacity = interpolate(presentationProgress, [0.18, 0.30], [0, 1], CLAMP);\n\n const words = message.split(/\\s+/).filter(Boolean);\n const wordStart = 0.30;\n const wordEnd = 0.70;\n const wordSlice = (wordEnd - wordStart) / Math.max(words.length, 1);\n\n const cardW = Math.min(width * 0.88, dim * 0.9);\n const cardPad = dim * 0.038;\n const cardRadius = dim * 0.025;\n const iconSize = dim * 0.045;\n const iconRadius = iconSize * 0.28;\n const iconFont = iconSize * 0.66;\n const appFontSize = dim * 0.030;\n const timeFontSize = dim * 0.026;\n const msgFontSize = dim * 0.040;\n\n const beatGlow = 1 + beatIntensity * 0.3;\n\n return (\n <div\n data-notification-card=\"true\"\n style={{\n position: \"absolute\",\n top: \"50%\",\n left: (width - cardW) / 2,\n width: cardW,\n transform: `translateY(calc(-50% + ${cardLift}px))`,\n opacity: cardOpacity,\n }}\n >\n <div\n style={{\n backgroundColor: \"rgba(245,242,238,0.92)\",\n borderRadius: cardRadius,\n padding: cardPad,\n boxShadow: `0 ${dim * 0.012}px ${dim * 0.05 * beatGlow}px rgba(0,0,0,0.18)`,\n transform: `scale(${cardScale})`,\n transformOrigin: \"center top\",\n display: \"flex\",\n flexDirection: \"column\",\n gap: dim * 0.018,\n }}\n >\n <div\n data-notification-header=\"true\"\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: dim * 0.014,\n opacity: headerOpacity,\n }}\n >\n <div\n style={{\n width: iconSize,\n height: iconSize,\n borderRadius: iconRadius,\n backgroundColor: \"rgba(0,0,0,0.06)\",\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n lineHeight: 1,\n fontSize: iconFont,\n }}\n >\n <Emoji char={appIcon} size={iconFont} verticalAlign=\"middle\" />\n </div>\n\n <span\n style={{\n fontSize: appFontSize,\n fontWeight: 600,\n color: \"rgba(0,0,0,0.65)\",\n letterSpacing: 0.2,\n }}\n >\n {appName}\n </span>\n\n <span\n style={{\n fontSize: timeFontSize,\n color: \"rgba(0,0,0,0.4)\",\n marginLeft: \"auto\",\n }}\n >\n now\n </span>\n </div>\n\n <div\n style={{\n fontSize: msgFontSize,\n fontWeight: 400,\n color: \"#1c1c1e\",\n lineHeight: 1.28,\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: `0 ${dim * 0.009}px`,\n }}\n >\n {words.map((word, wi) => {\n const wordP = interpolate(\n presentationProgress,\n [wordStart + wi * wordSlice, wordStart + wi * wordSlice + wordSlice * 0.6],\n [0, 1],\n CLAMP,\n );\n return (\n <span\n key={wi}\n style={{\n opacity: wordP,\n transform: `translateY(${(1 - wordP) * 4}px)`,\n display: \"inline-block\",\n }}\n >\n {renderWithEmoji(word, msgFontSize)}\n </span>\n );\n })}\n </div>\n </div>\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/media-source.ts",
|
|
44
|
-
"type": "registry:lib",
|
|
45
|
-
"target": "vanillasky/scene-templates/media-source.ts",
|
|
46
|
-
"content": "/**\n * How a scene's backdrop resolves from its variables. Pure, React-free, and\n * deliberately a leaf module: the player's media preloader shares it so the\n * question \"is this scene backed by a photo, a video, or the brand gradient?\"\n * has exactly one answer in the codebase.\n */\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\n/** Apple mobile browsers share WebKit's conservative video-decoder ceiling. */\nexport function limitsConcurrentVideoDecoders(): boolean {\n if (typeof navigator === \"undefined\") return false;\n return /iPad|iPhone|iPod/.test(navigator.userAgent) ||\n (navigator.platform === \"MacIntel\" && navigator.maxTouchPoints > 1);\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\n/**\n * True when the scene actually renders a photo or video backdrop — i.e. a\n * mediaUrl is set and the template has not been pinned to the brand gradient.\n * Templates use it to switch their type onto the media legibility recipe.\n */\nexport function hasSceneMedia(variables: Record<string, unknown>): boolean {\n return (\n String(variables.mediaUrl || \"\").trim() !== \"\" &&\n String(variables.mediaType || \"auto\") !== \"gradient\"\n );\n}\n"
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
"path": "src/visual-system/scene-templates/scene-background.tsx",
|
|
50
|
-
"type": "registry:component",
|
|
51
|
-
"target": "vanillasky/scene-templates/scene-background.tsx",
|
|
52
|
-
"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. Legibility is then split between two instruments:\n * eased scrims shaped to where the template's copy sits (`textAnchor`),\n * and a per-glyph halo on the type itself (MEDIA_TEXT_SHADOW). Neither\n * alone can hold white type over a blown-out highlight without flattening\n * the picture; together they do it at roughly half the darkening.\n * - mediaType=\"gradient\" deliberately ignores mediaUrl and renders only\n * the brand gradient. First-class atmospheric mode.\n * - When mediaUrl is empty, 404s, is blocked, or Pexels search returned\n * nothing, the gradient shows through cleanly and no scrim is painted —\n * a scrim over a bare gradient is just a muddy gradient. Enforced, not\n * assumed: the media has to load before anything darkens for it.\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, useState } from \"react\";\nimport {\n hasSceneMedia,\n resolveMediaType,\n type ResolvedMediaType,\n} from \"./media-source\";\nimport type { TemplateStyle } from \"../template-context\";\nimport { BrandGradientOverlay } from \"../backgrounds\";\nimport { getBackgroundTransform } from \"../backgrounds\";\nimport { useExternalVideoBackdrop } from \"./external-video-backdrop\";\nimport { resolveMediaPosition, type MediaPosition } from \"./media-position\";\nimport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\n\nexport { hasSceneMedia, resolveMediaType };\nexport { resolveMediaPosition } from \"./media-position\";\nexport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\nexport type { ResolvedMediaType };\n\nexport type { MediaPosition };\nexport type MediaTreatment = \"none\" | \"subtle\" | \"cinematic\" | \"text-safe\";\n\nexport function resolveMediaTreatment(value: string): MediaTreatment {\n return value === \"none\" || value === \"subtle\" || value === \"text-safe\" ? value : \"cinematic\";\n}\n\nexport interface MediaTreatmentLayer {\n id: \"vignette\" | \"center-scrim\" | \"bottom-scrim\";\n background: string;\n style?: React.CSSProperties;\n}\n\n/**\n * Where the template puts its type. The scrim is shaped to the copy, not to\n * the frame: darkening picture the type never touches costs contrast in the\n * photo and buys no legibility. \"full\" is the conservative default for\n * templates that have not declared an anchor.\n */\nexport type MediaTextAnchor = \"center\" | \"bottom\" | \"full\";\n\n/**\n * Smoothstep-sampled alpha stops between `start`% and `end`% of the gradient\n * box, held at full strength before `start` and after `end`.\n *\n * A two-stop `rgba(0,0,0,a) → transparent` scrim ramps alpha linearly, so it\n * ends with a constant slope. Lateral inhibition in the eye amplifies that\n * slope discontinuity into a visible band — the grey bar cutting across the\n * frame that makes an overlay read as an overlay. Smoothstep flattens the\n * curve at both ends, so the scrim holds where the type sits and then leaves\n * without an edge: the same peak coverage over the copy, noticeably less of\n * the picture spent getting there.\n */\nconst SCRIM_STOP_COUNT = 7;\n\nfunction smoothstep(t: number): number {\n return t * t * (3 - 2 * t);\n}\n\nfunction easedStops(\n peakAlpha: number,\n start: number,\n end: number,\n direction: \"fade-out\" | \"fade-in\",\n): string {\n const alphaAt = (t: number): string => {\n const eased = direction === \"fade-out\" ? 1 - smoothstep(t) : smoothstep(t);\n return `rgba(0,0,0,${Number((peakAlpha * eased).toFixed(3))})`;\n };\n const stops: string[] = [];\n if (start > 0) stops.push(`${alphaAt(0)} 0%`);\n for (let i = 0; i < SCRIM_STOP_COUNT; i += 1) {\n const t = i / (SCRIM_STOP_COUNT - 1);\n const position = Number((start + (end - start) * t).toFixed(2));\n stops.push(`${alphaAt(t)} ${position}%`);\n }\n if (end < 100) stops.push(`${alphaAt(1)} 100%`);\n return stops.join(\", \");\n}\n\n/**\n * Export-safe contrast recipes. Overlays only: SVG capture cannot rely on CSS\n * filters, so a blur-behind-text plate is off the table.\n *\n * The scrims deliberately stop short of solving legibility on their own. A\n * uniform darkening strong enough to carry white type over a blown-out sky\n * needs roughly 0.8 alpha — at that point the photo is a texture, not a\n * picture. The cheaper half of the job belongs to the type: a per-glyph halo\n * (MEDIA_TEXT_SHADOW) buys local contrast exactly where it is needed and\n * costs the image nothing. Scrim for the plate, halo for the glyph.\n */\nexport function getMediaTreatmentLayers(\n value: string,\n anchor: MediaTextAnchor = \"full\",\n): MediaTreatmentLayer[] {\n const treatment = resolveMediaTreatment(value);\n // Nothing over the picture at all. Every other treatment exists to carry\n // type across a photograph; where the scene has no type - a generated clip\n // that is the whole point of the beat - even a vignette is something the\n // viewer did not ask to look through.\n if (treatment === \"none\") return [];\n const vignette: MediaTreatmentLayer = {\n id: \"vignette\",\n background:\n treatment === \"subtle\"\n ? `radial-gradient(ellipse at center, ${easedStops(0.28, 45, 100, \"fade-in\")})`\n : `radial-gradient(ellipse at center, ${easedStops(0.72, 32, 100, \"fade-in\")})`,\n };\n if (treatment === \"subtle\") return [vignette];\n\n const textSafe = treatment === \"text-safe\";\n const layers: MediaTreatmentLayer[] = [vignette];\n\n if (anchor !== \"bottom\") {\n layers.push({\n id: \"center-scrim\",\n background: textSafe\n ? `radial-gradient(ellipse 92% 58% at 50% 50%, ${easedStops(0.46, 34, 90, \"fade-out\")})`\n : `radial-gradient(ellipse 88% 52% at 50% 50%, ${easedStops(0.26, 30, 88, \"fade-out\")})`,\n });\n }\n\n if (anchor !== \"center\") {\n layers.push({\n id: \"bottom-scrim\",\n background: `linear-gradient(to top, ${easedStops(textSafe ? 0.64 : 0.5, 8, 100, \"fade-out\")})`,\n style: { top: \"55%\" },\n });\n }\n\n return layers;\n}\n\n/**\n * Whether the backdrop is actually painting, which is what decides if a scrim\n * is earned. \"pending\" is a browser-only state: static and export renders\n * never run effects and never wait on a network, so they start (and stay)\n * ready and their output is unchanged.\n */\ntype MediaPaintState = \"pending\" | \"ready\" | \"failed\";\n\nfunction initialMediaPaint(\n wantsMedia: boolean,\n resolved: ResolvedMediaType,\n mediaUrl: string,\n mediaPoster: string | undefined,\n): MediaPaintState {\n if (typeof window === \"undefined\") return \"ready\";\n if (!wantsMedia) return \"ready\";\n // A poster paints the video's frame immediately, so the scene is already\n // showing footage even though the stream is still decoding.\n if (resolved === \"video\") return mediaPoster ? \"ready\" : \"pending\";\n if (typeof Image === \"undefined\") return \"ready\";\n // Preloaded or browser-cached media decodes synchronously. Reporting it\n // ready on the first render keeps the common mid-playback case free of a\n // gradient-then-photo flicker.\n const cached = new Image();\n cached.src = mediaUrl;\n return cached.complete && cached.naturalWidth > 0 ? \"ready\" : \"pending\";\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 /** Where this template's copy sits, so the scrim is shaped to the type\n * instead of to the frame. Defaults to \"full\" (scrim both the middle and\n * the lower third) for templates that have not declared an anchor. */\n textAnchor?: MediaTextAnchor;\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 textAnchor = \"full\",\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 wantsMedia = resolved !== \"gradient\" && !!mediaUrl;\n const externalVideoBackdrop = useExternalVideoBackdrop();\n const hasExternalVideoBackdrop = externalVideoBackdrop !== false && resolved === \"video\";\n const externalVideoFailed = externalVideoBackdrop === \"fallback\" && resolved === \"video\";\n const externalVideoReady = externalVideoBackdrop === \"ready\" && resolved === \"video\";\n\n // A scrim exists to hold type against footage. Until the footage is on\n // screen there is nothing to hold it against, so the scrim would just be\n // darkening the brand gradient it was never meant to touch — the scene\n // reads as a muddy, vignetted version of the gradient scenes beside it.\n // That window is not rare: it covers the whole load, and it never ends for\n // a dead URL, a blocked host, or an empty stock search.\n //\n // So the media has to paint before anything darkens for it. Both edges of\n // the swap land on the same commit — scrim and picture appear together,\n // and the fallback is the clean gradient the docs always promised.\n const [mediaPaint, setMediaPaint] = useState<MediaPaintState>(() =>\n initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster),\n );\n useEffect(() => {\n setMediaPaint(initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster));\n // Video reports its own paint through onLoadedData / onError below.\n if (!wantsMedia || resolved !== \"photo\") return;\n if (typeof Image === \"undefined\") return;\n let cancelled = false;\n const probe = new Image();\n probe.onload = () => {\n if (!cancelled) setMediaPaint(\"ready\");\n };\n probe.onerror = () => {\n if (!cancelled) setMediaPaint(\"failed\");\n };\n probe.src = mediaUrl;\n if (probe.complete) setMediaPaint(probe.naturalWidth > 0 ? \"ready\" : \"failed\");\n return () => {\n cancelled = true;\n probe.onload = null;\n probe.onerror = null;\n };\n }, [mediaUrl, mediaPoster, resolved, wantsMedia]);\n\n // The element stays mounted while pending — that is what loads it. Only a\n // confirmed failure takes it back out.\n const showMedia = wantsMedia && mediaPaint !== \"failed\";\n const showTreatment = wantsMedia && mediaPaint === \"ready\";\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const resolvedTreatment = resolveMediaTreatment(mediaTreatment);\n const treatmentLayers = getMediaTreatmentLayers(resolvedTreatment, textAnchor);\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 return (\n <>\n {(!hasExternalVideoBackdrop || externalVideoFailed) && (\n <BrandGradientOverlay\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n seed={gradSeed}\n />\n )}\n\n {showMedia && !hasExternalVideoBackdrop &&\n (resolved === \"video\" ? (\n <SceneVideoBackdrop\n mediaUrl={mediaUrl}\n mediaPoster={mediaPoster}\n mediaPosition={mediaPosition}\n backgroundEffect={backgroundEffect}\n progress={progress}\n beatIntensity={beatIntensity}\n isPlaying={isPlaying}\n onReady={() => setMediaPaint(\"ready\")}\n onError={() => setMediaPaint(\"failed\")}\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 {(hasExternalVideoBackdrop ? externalVideoReady : showTreatment) && !externalVideoFailed &&\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"
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
"path": "src/visual-system/scene-templates/types.ts",
|
|
56
|
-
"type": "registry:lib",
|
|
57
|
-
"target": "vanillasky/scene-templates/types.ts",
|
|
58
|
-
"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"
|
|
59
|
-
}
|
|
60
|
-
],
|
|
61
|
-
"meta": {
|
|
62
|
-
"vanillasky": {
|
|
63
|
-
"layer": "template",
|
|
64
|
-
"tier": "free",
|
|
65
|
-
"register": "card-led",
|
|
66
|
-
"jobs": [
|
|
67
|
-
"setup",
|
|
68
|
-
"proof"
|
|
69
|
-
],
|
|
70
|
-
"useWhen": "A real alert, reminder, approval, completion, update, or ready-state is a product moment.",
|
|
71
|
-
"textCanvas": "tight",
|
|
72
|
-
"minDuration": 2,
|
|
73
|
-
"preferredDuration": 3,
|
|
74
|
-
"schema": {
|
|
75
|
-
"type": "object",
|
|
76
|
-
"properties": {
|
|
77
|
-
"appName": {
|
|
78
|
-
"type": "string",
|
|
79
|
-
"title": "App name",
|
|
80
|
-
"description": "App name shown in the notification header (e.g. Reminder, Lume, Linear, GitHub; 24 characters maximum).",
|
|
81
|
-
"minLength": 1,
|
|
82
|
-
"maxLength": 24,
|
|
83
|
-
"default": "Reminder"
|
|
84
|
-
},
|
|
85
|
-
"appIcon": {
|
|
86
|
-
"type": "string",
|
|
87
|
-
"title": "App icon",
|
|
88
|
-
"description": "Single emoji used as the app icon. Override the default 🔔 to retheme: 📅 reminder, ✅ task done, 💌 message, 🎉 celebration, ⚡ system event.",
|
|
89
|
-
"format": "emoji",
|
|
90
|
-
"maxLength": 16,
|
|
91
|
-
"default": "🔔"
|
|
92
|
-
},
|
|
93
|
-
"message": {
|
|
94
|
-
"type": "string",
|
|
95
|
-
"title": "Message",
|
|
96
|
-
"description": "The notification body. Keep it to a short alert of at most 100 characters; 1-2 lines reads best.",
|
|
97
|
-
"minLength": 1,
|
|
98
|
-
"maxLength": 100,
|
|
99
|
-
"default": "Believe in your dreams, they will lead you."
|
|
100
|
-
},
|
|
101
|
-
"mediaUrl": {
|
|
102
|
-
"type": "string",
|
|
103
|
-
"title": "Background media",
|
|
104
|
-
"description": "Optional photo or video URL behind this scene. When set, replaces the brand gradient.",
|
|
105
|
-
"format": "uri",
|
|
106
|
-
"default": ""
|
|
107
|
-
},
|
|
108
|
-
"mediaKeyword": {
|
|
109
|
-
"type": "string",
|
|
110
|
-
"title": "Background search keyword",
|
|
111
|
-
"description": "2-4 word English term for stock-media search (auto-fills mediaUrl).",
|
|
112
|
-
"format": "stock-media-keyword",
|
|
113
|
-
"default": ""
|
|
114
|
-
},
|
|
115
|
-
"mediaType": {
|
|
116
|
-
"type": "string",
|
|
117
|
-
"title": "Background media type",
|
|
118
|
-
"description": "auto detects photo/video from URL. 'gradient' is a deliberate mode — atmospheric brand-color scene with no stock footage.",
|
|
119
|
-
"enum": [
|
|
120
|
-
"auto",
|
|
121
|
-
"photo",
|
|
122
|
-
"video",
|
|
123
|
-
"gradient"
|
|
124
|
-
],
|
|
125
|
-
"default": "auto"
|
|
126
|
-
},
|
|
127
|
-
"mediaPoster": {
|
|
128
|
-
"type": "string",
|
|
129
|
-
"title": "Background poster image",
|
|
130
|
-
"description": "Still image URL shown while a video backdrop is decoding its first frame. A media resolver can supply a poster alongside 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.",
|
|
131
|
-
"format": "uri",
|
|
132
|
-
"default": ""
|
|
133
|
-
},
|
|
134
|
-
"mediaPosition": {
|
|
135
|
-
"type": "string",
|
|
136
|
-
"title": "Background focal position",
|
|
137
|
-
"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.",
|
|
138
|
-
"enum": [
|
|
139
|
-
"center",
|
|
140
|
-
"top",
|
|
141
|
-
"bottom",
|
|
142
|
-
"left",
|
|
143
|
-
"right"
|
|
144
|
-
],
|
|
145
|
-
"default": "center"
|
|
146
|
-
},
|
|
147
|
-
"mediaTreatment": {
|
|
148
|
-
"type": "string",
|
|
149
|
-
"title": "Background contrast treatment",
|
|
150
|
-
"description": "none leaves the picture untouched, for a scene carrying no type; subtle preserves a visual hero; cinematic adds balanced contrast; text-safe adds a stronger wash for copy-heavy scenes.",
|
|
151
|
-
"enum": [
|
|
152
|
-
"none",
|
|
153
|
-
"subtle",
|
|
154
|
-
"cinematic",
|
|
155
|
-
"text-safe"
|
|
156
|
-
],
|
|
157
|
-
"default": "cinematic"
|
|
158
|
-
}
|
|
159
|
-
},
|
|
160
|
-
"required": [
|
|
161
|
-
"appName",
|
|
162
|
-
"message"
|
|
163
|
-
],
|
|
164
|
-
"additionalProperties": false,
|
|
165
|
-
"x-vanillasky": {
|
|
166
|
-
"allowsStockMedia": true
|
|
167
|
-
}
|
|
168
|
-
},
|
|
169
|
-
"label": "Notification",
|
|
170
|
-
"description": "A compact app notification card appears over a branded background.",
|
|
171
|
-
"family": "Social & messaging",
|
|
172
|
-
"avoidWhen": "The message is a broad announcement, generic explanation, or not naturally notification-shaped.",
|
|
173
|
-
"usesGlobalTextEffect": false,
|
|
174
|
-
"usesGlobalTransition": true,
|
|
175
|
-
"usesGlobalBackgroundEffect": true,
|
|
176
|
-
"timing": {
|
|
177
|
-
"contentFields": [
|
|
178
|
-
"appName",
|
|
179
|
-
"message"
|
|
180
|
-
],
|
|
181
|
-
"contentUnit": "words"
|
|
182
|
-
},
|
|
183
|
-
"transitionTiming": {
|
|
184
|
-
"entryReadyProgress": 0.35,
|
|
185
|
-
"holdProgress": 0.7
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|