@vanillaskyai/video 0.8.8 → 0.10.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 +42 -0
- package/PUBLIC-API.md +5 -8
- package/dist/{builtin-server-CUYPIDE7.js → builtin-server-W4PLJUZ5.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 +5 -14
- package/dist/{chunk-AITKH6QT.js → chunk-2HFO22K2.js} +15 -40
- package/dist/chunk-4YM2M62S.js +13 -0
- package/dist/chunk-4ZJLPHBV.js +684 -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-2USVB3CY.js → chunk-ELVOT2FQ.js} +284 -177
- package/dist/chunk-EWWCQFTI.js +18 -0
- package/dist/{chunk-KT3M7KDL.js → chunk-HKUI5BTB.js} +3 -3
- package/dist/chunk-IIN5M5HW.js +697 -0
- package/dist/{chunk-LMG3GHLL.js → chunk-IQMYK5DX.js} +29 -3
- package/dist/{chunk-OWBT45EN.js → chunk-IR44XKBI.js} +1 -1
- package/dist/{chunk-MXZSDGZQ.js → chunk-NMFDC2W2.js} +4 -6
- 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-XX4GXKTX.js +35 -0
- package/dist/chunk-Z67Q5KUW.js +16 -0
- package/dist/{chunk-3VRMKFM7.js → chunk-ZDJNFA52.js} +11 -24
- package/dist/cinema-media-C7OUPNSH.js +12 -0
- package/dist/cli.js +18 -9
- package/dist/comparison-GNZOR7MF.js +37 -0
- package/dist/compose-video-TBFTONUC.js +12 -0
- package/dist/editorial-timeline-EMV5335S.js +43 -0
- package/dist/{events-BQ6z264n.d.ts → events-B4YCc4vc.d.ts} +1 -1
- package/dist/index.d.ts +4 -6
- package/dist/index.js +3 -7
- package/dist/key-figure-P37VK425.js +30 -0
- package/dist/{kit-tQnCPMOM.d.ts → kit-DrRpdn0p.d.ts} +2 -2
- package/dist/mobile-message-OF7T62B7.js +50 -0
- package/dist/quote-T6WYLN7A.js +33 -0
- package/dist/react.d.ts +21 -11
- package/dist/react.js +585 -826
- package/dist/scene-video-backdrop-PCTARAAJ.js +7 -0
- package/dist/server.d.ts +7 -8
- package/dist/server.js +179 -84
- package/dist/{system-prompt-RRXIWDDD.js → system-prompt-SYRT5DWM.js} +1 -1
- package/dist/template-catalog.d.ts +392 -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 +38 -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 +168 -0
- package/registry/items/editorialTimeline.json +172 -0
- package/registry/items/keyFigure.json +162 -0
- package/registry/items/mobileMessage.json +153 -0
- package/registry/items/quote.json +161 -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,210 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
-
"name": "tweet",
|
|
4
|
-
"type": "registry:block",
|
|
5
|
-
"title": "X post",
|
|
6
|
-
"description": "An X-style text post card with author, handle, verification, replies, and likes.",
|
|
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-tweet.tsx",
|
|
20
|
-
"type": "registry:component",
|
|
21
|
-
"target": "vanillasky/scene-templates/social-tweet.tsx",
|
|
22
|
-
"content": "/**\n * social-tweet — X-style post card on a media background.\n *\n * Card visuals + motion live in the TweetCard primitive\n * (src/visual-system/primitives/social/TweetCard.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 { resolveTokens } from \"../theme\";\nimport { SceneBackground, getMediaBackgroundProps } from \"./scene-background\";\nimport { TweetCard } from \"../primitives/social/TweetCard\";\n\nexport const SocialTweetTemplate: 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 authorName = String(variables.authorName || \"Your brand\");\n const message = String(variables.message || \"\");\n const { primary } = resolveTokens(style);\n const seed = (authorName + message).split(\"\").reduce((acc, c) => acc + c.charCodeAt(0), 0);\n\n /* Boolean field with a defensive string coercion for untyped model output. */\n const verifiedRaw = variables.authorVerified;\n const isVerified = verifiedRaw === true || String(verifiedRaw ?? \"\").toLowerCase() === \"true\";\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: \"'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif\",\n }}\n >\n {/* [slot: background] Gradient / media backdrop */}\n <SceneBackground\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n width={width}\n height={height}\n {...getMediaBackgroundProps(variables)}\n backgroundEffect={backgroundEffect}\n seed={seed}\n isPlaying={isPlaying}\n beatIntensity={beatIntensity}\n />\n\n {/* [slot: hero] Tweet card — TweetCard primitive (post text is the\n content; no separate caption slot) */}\n <TweetCard\n progress={progress}\n motionProgress={motionProgress}\n authorName={authorName}\n authorHandle={String(variables.authorHandle || \"\")}\n authorVerified={isVerified}\n message={message}\n targetLikes={Number(variables.likes) || 0}\n targetReplies={Number(variables.replies) || 0}\n accent={primary}\n width={width}\n height={height}\n beatIntensity={beatIntensity}\n />\n </div>\n );\n};\n"
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
"path": "src/visual-system/primitives/social/TweetCard.tsx",
|
|
26
|
-
"type": "registry:component",
|
|
27
|
-
"target": "vanillasky/primitives/social/TweetCard.tsx",
|
|
28
|
-
"content": "/**\n * TweetCard\n *\n * X-style post card: avatar + name + verified + handle on the left, X logo\n * top-right, tweet text below, footer with comment + heart counter rolls.\n * Card scales in via spring; counters roll up from 0 over the back half.\n *\n * social-tweet.tsx is refactored to consume this primitive; both paths\n * render byte-identical DOM.\n */\n\nimport * as React from \"react\";\nimport { interpolate, spring, SPRING_SMOOTH } from \"../../motion\";\nimport { autoTextColor } from \"../../theme\";\nimport { stripPipe } from \"../../typography\";\nimport { renderWithEmoji } from \"../../emoji/emoji-text\";\n\nconst CLAMP = { extrapolateLeft: \"clamp\" as const, extrapolateRight: \"clamp\" as const };\n\nconst X = {\n cardBg: \"#16181c\",\n cardBorder: \"#2f3336\",\n text: \"rgb(230, 230, 230)\",\n muted: \"#71767b\",\n verified: \"#1d9bf0\",\n};\n\nexport function formatCount(n: number): string {\n // Boundary rule: never let the leading number reach 4 digits — promote\n // to the next unit. Without this, 999,999 likes rounded to \"1000K\"\n // (mathematically right but breaks the K-suffix convention). Each\n // branch double-checks the rounded value: if rounding would push it\n // ≥1000, fall through to the next unit so 999,500 ≤ n < 1,000,000\n // displays as \"1M\" not \"1000K\".\n const abs = Math.abs(n);\n const sign = n < 0 ? \"-\" : \"\";\n if (abs < 1_000) return n.toLocaleString();\n if (abs < 10_000) {\n const v = abs / 1_000;\n const rounded = Number(v.toFixed(1));\n if (rounded < 10) return `${sign}${rounded.toString().replace(/\\.0$/, \"\")}K`;\n // falls through to the integer-K branch\n }\n if (abs < 1_000_000) {\n const k = Math.round(abs / 1_000);\n if (k < 1_000) return `${sign}${k}K`;\n // falls through to M\n }\n if (abs < 10_000_000) {\n const v = abs / 1_000_000;\n const rounded = Number(v.toFixed(1));\n if (rounded < 10) return `${sign}${rounded.toString().replace(/\\.0$/, \"\")}M`;\n }\n if (abs < 1_000_000_000) {\n const m = Math.round(abs / 1_000_000);\n if (m < 1_000) return `${sign}${m}M`;\n }\n const v = abs / 1_000_000_000;\n return `${sign}${v.toFixed(1).replace(/\\.0$/, \"\")}B`;\n}\n\nexport function hashHue(str: string): number {\n let h = 0;\n for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) % 360;\n return h;\n}\n\nexport function deriveHandle(brandName: string): string {\n const slug = brandName.toLowerCase().replace(/[^a-z0-9]/g, \"\");\n return slug ? `@${slug}` : \"\";\n}\n\nconst XLogo: React.FC<{ size: number }> = ({ size }) => (\n <svg width={size} height={size} viewBox=\"0 0 1200 1227\" fill=\"none\">\n <path\n d=\"M714.163 519.284 1160.89 0h-105.86L667.137 450.887 357.328 0H0l468.492 681.821L0 1226.37h105.866l409.625-476.152 327.181 476.152H1200L714.137 519.284h.026ZM569.165 687.828l-47.468-67.894-377.686-540.24h162.604l304.797 435.991 47.468 67.894 396.2 566.721H892.476L569.165 687.854v-.026Z\"\n fill={X.text}\n />\n </svg>\n);\n\nconst VerifiedBadge: React.FC<{ size: number }> = ({ size }) => (\n <svg width={size} height={size} viewBox=\"0 0 22 22\" fill=\"none\">\n <path\n d=\"M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44C12.276 1.819 11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85L6.8 12.46l1.41-1.42 2.262 2.26 4.798-5.23 1.47 1.36-6.198 6.77z\"\n fill={X.verified}\n />\n </svg>\n);\n\nconst CommentIcon: React.FC<{ size: number }> = ({ size }) => (\n <svg width={size} height={size} viewBox=\"0 0 24 24\" fill=\"none\" stroke={X.muted} strokeWidth={1.8} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n <path d=\"M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z\" />\n </svg>\n);\n\nconst HeartIcon: React.FC<{ size: number }> = ({ size }) => (\n <svg width={size} height={size} viewBox=\"0 0 24 24\" fill=\"none\" stroke={X.muted} strokeWidth={1.8} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n <path d=\"M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z\" />\n </svg>\n);\n\n// ─── Typed component (direct use from templates) ────────────────\n\nexport interface TweetCardProps {\n progress: number;\n motionProgress?: number;\n authorName: string;\n authorHandle?: string;\n authorVerified?: boolean;\n message: string;\n /** Decorative engagement counter. Default 0 (counter just rolls to 0). */\n targetLikes?: number;\n /** Decorative engagement counter. Default 0. */\n targetReplies?: number;\n /** Avatar background tint (defaults to hashed hue of authorName) */\n accent?: string;\n width: number;\n height: number;\n beatIntensity?: number;\n}\n\nexport const TweetCard: React.FC<TweetCardProps> = ({\n progress,\n motionProgress = progress,\n authorName: rawName,\n authorHandle: rawHandle,\n authorVerified,\n message: rawMessage,\n targetLikes = 0,\n targetReplies = 0,\n accent,\n width,\n height,\n beatIntensity = 0,\n}) => {\n const dim = Math.min(width, height);\n const s = dim / 1080;\n const resolvedAccent = accent || `hsl(${hashHue(String(rawName))}, 60%, 55%)`;\n\n const authorName = stripPipe(rawName);\n const customHandle = stripPipe(rawHandle || \"\").trim();\n const authorHandle = customHandle || deriveHandle(authorName);\n const isVerified = authorVerified !== false;\n const message = stripPipe(rawMessage);\n\n const initialChar = authorName.charAt(0).toUpperCase() || \"•\";\n\n const cardScale = spring(interpolate(motionProgress, [0, 0.24], [0, 1], CLAMP), SPRING_SMOOTH);\n const cardOpacity = interpolate(motionProgress, [0, 0.18], [0, 1], CLAMP);\n const replyCount = Math.round(interpolate(progress, [0.30, 0.75], [0, targetReplies], CLAMP));\n const likeCount = Math.round(interpolate(progress, [0.35, 0.80], [0, targetLikes], CLAMP));\n const replyDisplay = formatCount(replyCount);\n const likeDisplay = formatCount(likeCount);\n const replyIsTransient = replyDisplay !== formatCount(Math.round(targetReplies));\n const likeIsTransient = likeDisplay !== formatCount(Math.round(targetLikes));\n const beatScale = 1 + beatIntensity * 0.01;\n\n const cardWidth = Math.min(width * 0.92, dim * 0.95);\n const cardPad = dim * 0.05;\n const cardRadius = dim * 0.026;\n const avatarSize = dim * 0.09;\n const xLogoSize = dim * 0.072;\n const verifiedSize = dim * 0.028;\n const nameFontSize = dim * 0.04;\n const handleFontSize = dim * 0.030;\n const messageFontSize = dim * 0.046;\n const footerIconSize = dim * 0.032;\n const footerFontSize = dim * 0.030;\n const headerToBodyGap = dim * 0.028;\n const bodyToFooterGap = dim * 0.032;\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 <div\n style={{\n width: cardWidth,\n backgroundColor: X.cardBg,\n borderRadius: cardRadius,\n padding: cardPad,\n border: `1px solid ${X.cardBorder}`,\n boxShadow: [\n `0 ${1 * s}px ${3 * s}px 0 rgba(0,0,0,0.1)`,\n `0 ${1 * s}px ${2 * s}px ${-1 * s}px rgba(0,0,0,0.1)`,\n `0 ${20 * s}px ${60 * s}px rgba(0,0,0,0.45)`,\n ].join(\", \"),\n opacity: cardOpacity,\n transform: `scale(${cardScale * beatScale})`,\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: dim * 0.018 }}>\n <div\n style={{\n width: avatarSize,\n height: avatarSize,\n borderRadius: \"50%\",\n flexShrink: 0,\n overflow: \"hidden\",\n backgroundColor: resolvedAccent,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n }}\n >\n <span style={{ color: autoTextColor(resolvedAccent), fontSize: avatarSize * 0.46, fontWeight: 700, letterSpacing: -0.5, lineHeight: 1 }}>\n {initialChar}\n </span>\n </div>\n\n <div style={{ display: \"flex\", flexDirection: \"column\", flex: 1, minWidth: 0, gap: dim * 0.002 }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: dim * 0.006 }}>\n <span style={{ color: X.text, fontSize: nameFontSize, fontWeight: 700, letterSpacing: -0.3, lineHeight: 1.1, whiteSpace: \"nowrap\", overflow: \"hidden\", textOverflow: \"ellipsis\" }}>\n {authorName}\n </span>\n {isVerified && <VerifiedBadge size={verifiedSize} />}\n </div>\n <span style={{ color: X.muted, fontSize: handleFontSize, fontWeight: 400, lineHeight: 1.1, whiteSpace: \"nowrap\", overflow: \"hidden\", textOverflow: \"ellipsis\" }}>\n {authorHandle}\n </span>\n </div>\n\n <div style={{ flexShrink: 0 }}>\n <XLogo size={xLogoSize} />\n </div>\n </div>\n\n <div style={{ color: X.text, fontSize: messageFontSize, lineHeight: 1.4, letterSpacing: -0.1, marginTop: headerToBodyGap, wordBreak: \"break-word\" }}>\n {renderWithEmoji(message, messageFontSize)}\n </div>\n\n <div style={{ display: \"flex\", alignItems: \"center\", gap: dim * 0.04, marginTop: bodyToFooterGap }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: dim * 0.008 }}>\n <CommentIcon size={footerIconSize} />\n <span\n data-transition-semantic={replyIsTransient ? \"transient\" : undefined}\n style={{\n color: X.muted,\n fontSize: footerFontSize,\n fontVariantNumeric: \"tabular-nums\",\n ...(replyIsTransient\n ? { visibility: \"var(--vanillasky-transition-semantic-visibility,visible)\" as React.CSSProperties[\"visibility\"] }\n : {}),\n }}\n >\n {replyDisplay}\n </span>\n </div>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: dim * 0.008 }}>\n <HeartIcon size={footerIconSize} />\n <span\n data-transition-semantic={likeIsTransient ? \"transient\" : undefined}\n style={{\n color: X.muted,\n fontSize: footerFontSize,\n fontVariantNumeric: \"tabular-nums\",\n ...(likeIsTransient\n ? { visibility: \"var(--vanillasky-transition-semantic-visibility,visible)\" as React.CSSProperties[\"visibility\"] }\n : {}),\n }}\n >\n {likeDisplay}\n </span>\n </div>\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
|
-
"claim",
|
|
68
|
-
"proof"
|
|
69
|
-
],
|
|
70
|
-
"useWhen": "A grounded public announcement, founder update, launch quote, or concise social claim should read as a post.",
|
|
71
|
-
"textCanvas": "open",
|
|
72
|
-
"minDuration": 2.5,
|
|
73
|
-
"preferredDuration": 3.5,
|
|
74
|
-
"schema": {
|
|
75
|
-
"type": "object",
|
|
76
|
-
"properties": {
|
|
77
|
-
"authorName": {
|
|
78
|
-
"type": "string",
|
|
79
|
-
"title": "Author name",
|
|
80
|
-
"description": "Display name shown bold next to the verified checkmark.",
|
|
81
|
-
"default": "Your brand"
|
|
82
|
-
},
|
|
83
|
-
"authorHandle": {
|
|
84
|
-
"type": "string",
|
|
85
|
-
"title": "Handle",
|
|
86
|
-
"description": "Username in @format. Leave empty to auto-derive from author name (e.g. 'Linear' → '@linear').",
|
|
87
|
-
"default": ""
|
|
88
|
-
},
|
|
89
|
-
"authorVerified": {
|
|
90
|
-
"type": "boolean",
|
|
91
|
-
"title": "Verified checkmark",
|
|
92
|
-
"description": "Show the blue verified checkmark next to the author name.",
|
|
93
|
-
"examples": [
|
|
94
|
-
true
|
|
95
|
-
],
|
|
96
|
-
"default": false
|
|
97
|
-
},
|
|
98
|
-
"message": {
|
|
99
|
-
"type": "string",
|
|
100
|
-
"title": "Tweet text",
|
|
101
|
-
"description": "The tweet/post content. Reads best at 1-3 lines (~80-180 chars). Emoji land naturally at the end of a phrase.",
|
|
102
|
-
"default": "Just shipped the new feature 🚀"
|
|
103
|
-
},
|
|
104
|
-
"likes": {
|
|
105
|
-
"type": "number",
|
|
106
|
-
"title": "Likes",
|
|
107
|
-
"description": "Target like count (rolls up from 0).",
|
|
108
|
-
"examples": [
|
|
109
|
-
602
|
|
110
|
-
],
|
|
111
|
-
"default": 0
|
|
112
|
-
},
|
|
113
|
-
"replies": {
|
|
114
|
-
"type": "number",
|
|
115
|
-
"title": "Replies",
|
|
116
|
-
"description": "Target reply count (rolls up from 0).",
|
|
117
|
-
"examples": [
|
|
118
|
-
19
|
|
119
|
-
],
|
|
120
|
-
"default": 0
|
|
121
|
-
},
|
|
122
|
-
"mediaUrl": {
|
|
123
|
-
"type": "string",
|
|
124
|
-
"title": "Background media",
|
|
125
|
-
"description": "Optional photo or video URL behind this scene. When set, replaces the brand gradient.",
|
|
126
|
-
"format": "uri",
|
|
127
|
-
"default": ""
|
|
128
|
-
},
|
|
129
|
-
"mediaKeyword": {
|
|
130
|
-
"type": "string",
|
|
131
|
-
"title": "Background search keyword",
|
|
132
|
-
"description": "2-4 word English term for stock-media search (auto-fills mediaUrl).",
|
|
133
|
-
"format": "stock-media-keyword",
|
|
134
|
-
"default": ""
|
|
135
|
-
},
|
|
136
|
-
"mediaType": {
|
|
137
|
-
"type": "string",
|
|
138
|
-
"title": "Background media type",
|
|
139
|
-
"description": "auto detects photo/video from URL. 'gradient' is a deliberate mode — atmospheric brand-color scene with no stock footage.",
|
|
140
|
-
"enum": [
|
|
141
|
-
"auto",
|
|
142
|
-
"photo",
|
|
143
|
-
"video",
|
|
144
|
-
"gradient"
|
|
145
|
-
],
|
|
146
|
-
"default": "auto"
|
|
147
|
-
},
|
|
148
|
-
"mediaPoster": {
|
|
149
|
-
"type": "string",
|
|
150
|
-
"title": "Background poster image",
|
|
151
|
-
"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.",
|
|
152
|
-
"format": "uri",
|
|
153
|
-
"default": ""
|
|
154
|
-
},
|
|
155
|
-
"mediaPosition": {
|
|
156
|
-
"type": "string",
|
|
157
|
-
"title": "Background focal position",
|
|
158
|
-
"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.",
|
|
159
|
-
"enum": [
|
|
160
|
-
"center",
|
|
161
|
-
"top",
|
|
162
|
-
"bottom",
|
|
163
|
-
"left",
|
|
164
|
-
"right"
|
|
165
|
-
],
|
|
166
|
-
"default": "center"
|
|
167
|
-
},
|
|
168
|
-
"mediaTreatment": {
|
|
169
|
-
"type": "string",
|
|
170
|
-
"title": "Background contrast treatment",
|
|
171
|
-
"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.",
|
|
172
|
-
"enum": [
|
|
173
|
-
"none",
|
|
174
|
-
"subtle",
|
|
175
|
-
"cinematic",
|
|
176
|
-
"text-safe"
|
|
177
|
-
],
|
|
178
|
-
"default": "cinematic"
|
|
179
|
-
}
|
|
180
|
-
},
|
|
181
|
-
"required": [
|
|
182
|
-
"authorName",
|
|
183
|
-
"message"
|
|
184
|
-
],
|
|
185
|
-
"additionalProperties": false,
|
|
186
|
-
"x-vanillasky": {
|
|
187
|
-
"allowsStockMedia": true
|
|
188
|
-
}
|
|
189
|
-
},
|
|
190
|
-
"label": "X post",
|
|
191
|
-
"description": "An X-style text post card with author, handle, verification, replies, and likes.",
|
|
192
|
-
"family": "Social & messaging",
|
|
193
|
-
"avoidWhen": "The copy is generic explanatory prose, attribution is missing, or visual attachments are essential.",
|
|
194
|
-
"usesGlobalTextEffect": false,
|
|
195
|
-
"usesGlobalTransition": true,
|
|
196
|
-
"usesGlobalBackgroundEffect": true,
|
|
197
|
-
"timing": {
|
|
198
|
-
"contentFields": [
|
|
199
|
-
"authorName",
|
|
200
|
-
"message"
|
|
201
|
-
],
|
|
202
|
-
"contentUnit": "words"
|
|
203
|
-
},
|
|
204
|
-
"transitionTiming": {
|
|
205
|
-
"entryReadyProgress": 0.2,
|
|
206
|
-
"holdProgress": 0.7
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
}
|