@vanillaskyai/video 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/LICENSE +201 -0
- package/PUBLIC-API.md +276 -0
- package/README.md +152 -0
- package/SECURITY.md +22 -0
- package/SUPPORT.md +28 -0
- package/bin/vanillasky.js +3 -0
- package/dist/bg-confetti-WNQXS7ZS.js +89 -0
- package/dist/bg-emoji-ETI47GLS.js +150 -0
- package/dist/bg-media-SVUZAMGX.js +99 -0
- package/dist/brand-message-F3RD4P3P.js +253 -0
- package/dist/builtin-server-F7YXRVCH.js +8 -0
- package/dist/catalog-types-BIhSpOWK.d.ts +74 -0
- package/dist/chart-bar-CNSC7BNK.js +304 -0
- package/dist/chart-counter-MCRHKN77.js +299 -0
- package/dist/chart-progress-ring-RMI4A7ZK.js +296 -0
- package/dist/check-runtime.d.ts +15 -0
- package/dist/check-runtime.js +102 -0
- package/dist/chunk-2E6T633S.js +27 -0
- package/dist/chunk-352ZZCET.js +83 -0
- package/dist/chunk-73NTSFFI.js +81 -0
- package/dist/chunk-A2X45FET.js +46 -0
- package/dist/chunk-ABPKM4GK.js +187 -0
- package/dist/chunk-ABZACD4G.js +216 -0
- package/dist/chunk-BCRUUJ6A.js +756 -0
- package/dist/chunk-CHLGW22F.js +3551 -0
- package/dist/chunk-CJ7F2CE6.js +104 -0
- package/dist/chunk-CR7FE7BW.js +109 -0
- package/dist/chunk-E7CL7UPB.js +109 -0
- package/dist/chunk-EE6PK6XC.js +18 -0
- package/dist/chunk-ERY4S6QV.js +385 -0
- package/dist/chunk-EVG2ZYIY.js +125 -0
- package/dist/chunk-FNAEQ7QO.js +155 -0
- package/dist/chunk-FQ47WXAJ.js +38 -0
- package/dist/chunk-FSG2PYWG.js +336 -0
- package/dist/chunk-GHJEAP4O.js +504 -0
- package/dist/chunk-GRMUV7QY.js +262 -0
- package/dist/chunk-IFOW65Z6.js +205 -0
- package/dist/chunk-IXCUY3B3.js +786 -0
- package/dist/chunk-L5BTTCZV.js +97 -0
- package/dist/chunk-LMG3GHLL.js +107 -0
- package/dist/chunk-OOPIETLW.js +101 -0
- package/dist/chunk-P22HHFDK.js +43 -0
- package/dist/chunk-QAAO7OAY.js +19 -0
- package/dist/chunk-S7HFLYNN.js +35 -0
- package/dist/chunk-SPVTJH3F.js +24 -0
- package/dist/chunk-W2XJKG6C.js +3177 -0
- package/dist/chunk-XB7LH4J2.js +301 -0
- package/dist/chunk-XGRA2MUV.js +242 -0
- package/dist/chunk-XTOXFMLM.js +105 -0
- package/dist/chunk-YC5MFXA5.js +132 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1681 -0
- package/dist/compose-video-CN3VSWST.js +13 -0
- package/dist/cta-logo-SVA5GP2A.js +209 -0
- package/dist/cta-media-E7VLLK42.js +215 -0
- package/dist/events-_wfjbmEp.d.ts +81 -0
- package/dist/incoming-call-62MFZL3Z.js +320 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +14 -0
- package/dist/infographic-before-after-AXCDAWND.js +460 -0
- package/dist/infographic-feature-list-GW5GUE6O.js +266 -0
- package/dist/infographic-problem-solution-7NURJANA.js +340 -0
- package/dist/infographic-stat-row-J7IZR6ZN.js +241 -0
- package/dist/infographic-steps-VSZM6F5M.js +400 -0
- package/dist/kit-BMNcHqa9.d.ts +79 -0
- package/dist/prompt-input-LOUCRXCY.js +236 -0
- package/dist/react.d.ts +81 -0
- package/dist/react.js +835 -0
- package/dist/reaction-QG4CYZLQ.js +119 -0
- package/dist/server.d.ts +141 -0
- package/dist/server.js +498 -0
- package/dist/showcase-code-RUTFZR3H.js +351 -0
- package/dist/showcase-phone-6CP6XEU5.js +272 -0
- package/dist/showcase-terminal-MGAEQWPR.js +306 -0
- package/dist/showcase-web-Y4W2HVRJ.js +390 -0
- package/dist/social-conversation-75SD2637.js +695 -0
- package/dist/social-milestone-XI2QE5BO.js +292 -0
- package/dist/social-notification-2UOHQCUY.js +244 -0
- package/dist/social-review-stack-FDYZGZTJ.js +239 -0
- package/dist/social-testimonial-OQ7N52GJ.js +284 -0
- package/dist/social-tweet-5OLCNPPC.js +295 -0
- package/dist/state-DZcKuS32.d.ts +3 -0
- package/dist/system-prompt-A6UXNTPK.js +8 -0
- package/dist/template-catalog.d.ts +24 -0
- package/dist/template-catalog.js +6 -0
- package/dist/templates.d.ts +25 -0
- package/dist/templates.js +19 -0
- package/dist/test.d.ts +149 -0
- package/dist/test.js +272 -0
- package/dist/text-stream-6XHC7J47.js +10 -0
- package/dist/types-B-PZR6G_.d.ts +208 -0
- package/dist/validate-T7GBU2YF.js +8 -0
- package/docs/agent-integration.md +39 -0
- package/docs/architecture.md +84 -0
- package/docs/branding-and-personalization.md +87 -0
- package/docs/concepts.md +106 -0
- package/docs/custom-templates.md +329 -0
- package/docs/customization.md +141 -0
- package/docs/errors.md +70 -0
- package/docs/getting-started.md +111 -0
- package/docs/input-and-first-scene.md +41 -0
- package/docs/integrate-nextjs.md +79 -0
- package/docs/media-and-audio.md +69 -0
- package/docs/motion-and-effects.md +107 -0
- package/docs/persistence.md +95 -0
- package/docs/production.md +122 -0
- package/docs/prompt-and-input.md +185 -0
- package/docs/provider-integration.md +115 -0
- package/docs/reference/protocol.md +96 -0
- package/docs/reference/provider-adapters.md +158 -0
- package/docs/responsive-orientation.md +37 -0
- package/docs/security.md +38 -0
- package/docs/streaming-protocol.md +15 -0
- package/docs/testing.md +137 -0
- package/docs/use-cases.md +59 -0
- package/examples/custom-template/README.md +19 -0
- package/examples/custom-template/minimal-text.tsx +82 -0
- package/examples/custom-template/structured-data.tsx +104 -0
- package/examples/custom-template/supplied-media.tsx +92 -0
- package/examples/nextjs-quickstart/.env.example +2 -0
- package/examples/nextjs-quickstart/README.md +22 -0
- package/examples/nextjs-quickstart/next-env.d.ts +4 -0
- package/examples/nextjs-quickstart/package.json +24 -0
- package/examples/nextjs-quickstart/src/app/api/video/route.ts +22 -0
- package/examples/nextjs-quickstart/src/app/layout.tsx +5 -0
- package/examples/nextjs-quickstart/src/app/page.tsx +31 -0
- package/examples/nextjs-quickstart/tsconfig.json +26 -0
- package/package.json +167 -0
- package/registry/items/backgrounds.json +52 -0
- package/registry/items/barChart.json +237 -0
- package/registry/items/beforeAfter.json +180 -0
- package/registry/items/bigNumber.json +225 -0
- package/registry/items/brandMessage.json +158 -0
- package/registry/items/cardList.json +206 -0
- package/registry/items/chatMessenger.json +151 -0
- package/registry/items/chatWhatsapp.json +151 -0
- package/registry/items/codeEditor.json +184 -0
- package/registry/items/confetti.json +107 -0
- package/registry/items/ctaLogo.json +160 -0
- package/registry/items/ctaMedia.json +193 -0
- package/registry/items/emojiBurst.json +101 -0
- package/registry/items/incomingCall.json +170 -0
- package/registry/items/media.json +179 -0
- package/registry/items/milestone.json +205 -0
- package/registry/items/motion.json +45 -0
- package/registry/items/notification.json +182 -0
- package/registry/items/phoneMockup.json +254 -0
- package/registry/items/problemSolution.json +186 -0
- package/registry/items/progressRing.json +199 -0
- package/registry/items/promptInput.json +148 -0
- package/registry/items/reaction.json +170 -0
- package/registry/items/reviewStack.json +224 -0
- package/registry/items/steps.json +207 -0
- package/registry/items/template-context.json +31 -0
- package/registry/items/terminal.json +212 -0
- package/registry/items/testimonial.json +164 -0
- package/registry/items/theme.json +47 -0
- package/registry/items/tripleStats.json +228 -0
- package/registry/items/tweet.json +203 -0
- package/registry/items/typography.json +47 -0
- package/registry/items/webMockup.json +273 -0
|
@@ -0,0 +1,203 @@
|
|
|
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: \"#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/scene-background.tsx",
|
|
44
|
+
"type": "registry:component",
|
|
45
|
+
"target": "vanillasky/scene-templates/scene-background.tsx",
|
|
46
|
+
"content": "/**\n * SceneBackground — shared backdrop component for any scene template that\n * wants to support both a brand-color gradient and stock media (Pexels\n * photo / video) as an alternate atmosphere.\n *\n * Usage:\n * <SceneBackground\n * style={style}\n * progress={progress}\n * sceneDuration={sceneDuration}\n * width={width}\n * height={height}\n * mediaUrl={String(variables.mediaUrl || \"\")}\n * mediaType={String(variables.mediaType || \"auto\")}\n * seed={String(variables.texts || \"\")}\n * isPlaying={isPlaying}\n * />\n * ... template's content layered on top\n *\n * Behavior:\n * - Brand gradient is the always-on backdrop (uses BrandGradientOverlay).\n * - When mediaUrl is set and mediaType isn't \"gradient\", the photo/video\n * covers the gradient. Vignette + bottom-half darken give the content\n * contrast against busy footage.\n * - mediaType=\"gradient\" deliberately ignores mediaUrl and renders only\n * the brand gradient. First-class atmospheric mode.\n * - When mediaUrl is empty / 404s / Pexels search returned nothing,\n * gradient shows through cleanly (matches every other gradient-backed\n * template).\n *\n * Extracted from bg-media.tsx so any template can compose it. bg-media\n * now uses this component too — its \"media is the scene\" identity comes\n * from how it positions the title (centered, full-frame), not from\n * duplicated render logic.\n */\n\nimport React, { useEffect, useRef } from \"react\";\nimport type { TemplateStyle } from \"../template-context\";\nimport { BrandGradientOverlay } from \"../backgrounds\";\nimport { getBackgroundTransform } from \"../backgrounds\";\n\nconst VIDEO_EXTENSIONS = [\".mp4\", \".webm\", \".mov\", \".m4v\", \".avi\"];\n\nfunction isVideoUrl(url: string): boolean {\n try {\n const pathname = new URL(url).pathname.toLowerCase();\n return VIDEO_EXTENSIONS.some((ext) => pathname.endsWith(ext));\n } catch {\n const lower = url.toLowerCase();\n return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext));\n }\n}\n\nexport type ResolvedMediaType = \"photo\" | \"video\" | \"gradient\";\n\nexport type MediaPosition = \"center\" | \"top\" | \"bottom\" | \"left\" | \"right\";\nexport type MediaTreatment = \"subtle\" | \"cinematic\" | \"text-safe\";\n\nconst MEDIA_POSITIONS: Record<MediaPosition, string> = {\n center: \"center center\",\n top: \"center top\",\n bottom: \"center bottom\",\n left: \"left center\",\n right: \"right center\",\n};\n\nexport function resolveMediaPosition(value: string): string {\n return MEDIA_POSITIONS[value as MediaPosition] ?? MEDIA_POSITIONS.center;\n}\n\nexport function resolveMediaTreatment(value: string): MediaTreatment {\n return value === \"subtle\" || value === \"text-safe\" ? value : \"cinematic\";\n}\n\nexport interface MediaTreatmentLayer {\n id: \"vignette\" | \"full-wash\" | \"center-scrim\" | \"bottom-scrim\";\n background: string;\n style?: React.CSSProperties;\n}\n\n/** Export-safe contrast recipes. Overlays only: SVG capture cannot rely on CSS filters. */\nexport function getMediaTreatmentLayers(value: string): MediaTreatmentLayer[] {\n const treatment = resolveMediaTreatment(value);\n const vignette: MediaTreatmentLayer = {\n id: \"vignette\",\n background:\n treatment === \"subtle\"\n ? \"radial-gradient(ellipse at center, transparent 45%, rgba(0,0,0,0.28) 100%)\"\n : \"radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,0.55) 80%, rgba(0,0,0,0.75) 100%)\",\n };\n if (treatment === \"subtle\") return [vignette];\n\n const cinematic: MediaTreatmentLayer[] = [\n vignette,\n {\n id: \"center-scrim\",\n background:\n treatment === \"text-safe\"\n ? \"radial-gradient(ellipse 90% 56% at 50% 50%, rgba(0,0,0,0.36) 0%, rgba(0,0,0,0.18) 55%, transparent 84%)\"\n : \"radial-gradient(ellipse 85% 50% at 50% 50%, rgba(0,0,0,0.22) 0%, rgba(0,0,0,0.10) 50%, transparent 80%)\",\n },\n {\n id: \"bottom-scrim\",\n background:\n treatment === \"text-safe\"\n ? \"linear-gradient(to top, rgba(0,0,0,0.68) 0%, transparent 100%)\"\n : \"linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 100%)\",\n style: { top: \"55%\" },\n },\n ];\n\n if (treatment === \"text-safe\") {\n cinematic.splice(1, 0, {\n id: \"full-wash\",\n background: \"rgba(0,0,0,0.30)\",\n });\n }\n return cinematic;\n}\n\nexport function resolveMediaType(\n mediaType: string,\n mediaUrl: string,\n): ResolvedMediaType {\n if (mediaType === \"gradient\") return \"gradient\";\n if (mediaType === \"video\") return \"video\";\n if (mediaType === \"photo\") return \"photo\";\n // \"auto\" — detect from URL extension\n return mediaUrl && isVideoUrl(mediaUrl) ? \"video\" : \"photo\";\n}\n\nexport function getMediaBackgroundProps(variables: Record<string, unknown>) {\n return {\n mediaUrl: String(variables.mediaUrl || \"\"),\n mediaType: String(variables.mediaType || \"auto\"),\n mediaPoster: String(variables.mediaPoster || \"\"),\n mediaPosition: String(variables.mediaPosition || \"center\"),\n mediaTreatment: String(variables.mediaTreatment || \"cinematic\"),\n };\n}\n\nexport interface SceneBackgroundProps {\n style: TemplateStyle;\n progress: number;\n sceneDuration?: number;\n width: number;\n height: number;\n mediaUrl?: string;\n mediaType?: string;\n /** Still image URL shown while the <video> backdrop decodes its first\n * frame. Without it the element renders transparent during the\n * ~50–400ms decode window and the gradient flashes through. */\n mediaPoster?: string;\n /** Cover-crop focal anchor. Keeps the important edge/subject visible. */\n mediaPosition?: string;\n /** Overlay recipe: subtle, cinematic, or stronger text-safe contrast. */\n mediaTreatment?: string;\n /** Background motion effect (drift / pulse / Ken Burns). Applied to the photo/video. */\n backgroundEffect?: string;\n /** Stable seed for the gradient breathing animation. Pass the scene's\n * text content (or any stable string) — it's hashed deterministically. */\n seed?: number | string;\n /** Pause video when preview is paused. Defaults to true (export path). */\n isPlaying?: boolean;\n beatIntensity?: number;\n}\n\nexport const SceneBackground: React.FC<SceneBackgroundProps> = ({\n style,\n progress,\n sceneDuration,\n width: _width, // accepted for symmetry; not currently used in render\n height: _height,\n mediaUrl = \"\",\n mediaType = \"auto\",\n mediaPoster,\n mediaPosition = \"center\",\n mediaTreatment = \"cinematic\",\n backgroundEffect,\n seed,\n isPlaying = true,\n beatIntensity = 0,\n}) => {\n void _width;\n void _height;\n const resolved = resolveMediaType(mediaType, mediaUrl);\n const showMedia = resolved !== \"gradient\" && !!mediaUrl;\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const resolvedTreatment = resolveMediaTreatment(mediaTreatment);\n const treatmentLayers = getMediaTreatmentLayers(resolvedTreatment);\n\n const gradSeed =\n typeof seed === \"number\"\n ? seed\n : typeof seed === \"string\"\n ? seed.split(\"\").reduce((acc, c) => acc + c.charCodeAt(0), 0)\n : 0;\n\n const bgTransform = getBackgroundTransform(\n backgroundEffect,\n progress,\n beatIntensity,\n );\n\n // Video playback control — same pause/seek logic bg-media used pre-extract.\n const videoRef = useRef<HTMLVideoElement>(null);\n const lastProgress = useRef(progress);\n const videoStarted = useRef(false);\n\n useEffect(() => {\n const vid = videoRef.current;\n if (!vid) return;\n if (!isPlaying) {\n vid.pause();\n videoStarted.current = false;\n return;\n }\n const progressChanged = Math.abs(progress - lastProgress.current) > 0.001;\n lastProgress.current = progress;\n if (progressChanged && !videoStarted.current) {\n vid.playbackRate = 1;\n vid.currentTime = 0;\n vid.play().catch(() => {});\n videoStarted.current = true;\n } else if (!progressChanged && videoStarted.current) {\n vid.pause();\n videoStarted.current = false;\n }\n }, [progress, isPlaying]);\n\n // Release the decoder on unmount. Without this, iOS Safari keeps the\n // video's decoder buffer alive after the React node is gone — each\n // scene transition (or play/pause/play cycle that remounts the active\n // scene) leaks one decoder, eventually crossing the renderer's memory\n // ceiling and triggering \"A problem repeatedly occurred.\" Same recipe\n // as #409's CanvasPreview preload cleanup: pause → clear src → load().\n // Capture the ref at mount-time so the cleanup uses the same node we\n // mounted (the ref's .current is stale by unmount).\n useEffect(() => {\n const vid = videoRef.current;\n return () => {\n if (!vid) return;\n vid.pause();\n vid.removeAttribute(\"src\");\n vid.load();\n };\n }, []);\n\n return (\n <>\n <BrandGradientOverlay\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n seed={gradSeed}\n />\n\n {showMedia &&\n (resolved === \"video\" ? (\n <video\n ref={videoRef}\n src={mediaUrl}\n // Poster paints during the decode window so the user sees the\n // (still) first frame instead of a transparent <video> letting\n // the brand gradient show through. Pexels returns a thumbnail\n // image alongside each video; fillPexelsUrls stores it in\n // `variables.mediaPoster`. Layered defense alongside preload=\"auto\"\n // below: on desktop the byte preloader makes decode fast, on\n // mobile (where the preloader skips video pre-mounting to dodge\n // the iOS Safari memory crash) the poster is the primary shield.\n poster={mediaPoster || undefined}\n muted\n loop\n playsInline\n // preload=\"auto\" — without it, browsers default to \"metadata\":\n // they only load the container/dimensions, not the byte stream\n // needed to decode frames. The element then renders transparent\n // until the first decoded frame arrives, letting the brand\n // gradient flash through whenever a scene mid-playback transitions\n // to a media backdrop. The parent preloader caches the bytes, but\n // decoder state is per-element, so the active mount still has to\n // decode the first frame; \"auto\" kicks that work off the instant\n // the element mounts.\n preload=\"auto\"\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: resolvedPosition,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n }}\n />\n ) : (\n <div\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n backgroundImage: `url(${mediaUrl})`,\n backgroundSize: \"cover\",\n backgroundPosition: resolvedPosition,\n }}\n />\n ))}\n\n {showMedia &&\n treatmentLayers.map((layer) => (\n <div\n key={layer.id}\n data-media-treatment={resolvedTreatment}\n data-media-overlay={layer.id}\n style={{\n position: \"absolute\",\n inset: 0,\n background: layer.background,\n pointerEvents: \"none\",\n ...layer.style,\n }}\n />\n ))}\n </>\n );\n};\n"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"path": "src/visual-system/scene-templates/types.ts",
|
|
50
|
+
"type": "registry:lib",
|
|
51
|
+
"target": "vanillasky/scene-templates/types.ts",
|
|
52
|
+
"content": "/**\n * Scene template types.\n *\n * A template is a reusable React component that defines how a scene looks.\n * It declares the variables an editor or model may provide\n * and receives universal settings as props.\n *\n * Templates are searchable by AI via description, category, jobs, register,\n * and useWhen guidance.\n * The variable schema enables any LLM to fill in template variables via JSON.\n */\n\nimport type { ResolvedTokens } from \"../theme\";\nimport type { SafeZone, TemplateStyle } from \"../template-context\";\n\n/**\n * Props passed to every scene template component.\n *\n * All animation must be driven by `progress` (0→1). No CSS animations,\n * no Framer Motion, no requestAnimationFrame. Use interpolate/spring\n * from animation-utils.ts.\n *\n * Scale factor: use `Math.min(width, height) / 1080` — normalizes to\n * the short edge so visuals are consistent across portrait and landscape.\n */\nexport interface SceneTemplateProps {\n variables: Record<string, unknown>;\n style: TemplateStyle;\n /** 0→1 through the scene's duration */\n progress: number;\n /** Presentation clock. Active templates receive the same complete 0→1 timeline as progress. */\n motionProgress?: number;\n /** 0→1 beat pulse intensity */\n beatIntensity: number;\n /** 1080 (portrait) or 1920 (landscape) */\n width: number;\n /** 1920 (portrait) or 1080 (landscape) */\n height: number;\n /** Video-level default text effect (for templates that opt in via usesGlobalTextEffect) */\n textArchetype?: string;\n /** How text leaves the scene (fade / shrink / pop / blur-scale). Falls back to a sensible default per textArchetype when undefined. */\n /** Video-level default background effect (for templates that opt in via usesGlobalBackgroundEffect) */\n backgroundEffect?: string;\n /** Platform-aware safe zone insets in pixels — use for text placement */\n safeZone: SafeZone;\n /** Scene duration in seconds — use for time-based (not progress-based) animations */\n sceneDuration?: number;\n /**\n * Brand tokens already resolved from `style`. Built-in templates import\n * resolveTokens directly; an ejected `custom_*` scene can't import anything,\n * so without this it has no way to reach the same values and ends up\n * hardcoding white, black and shadows — the body then looks generic next to\n * a frame that IS using the brand.\n */\n tokens?: ResolvedTokens;\n /**\n * True when the preview player is actively advancing progress; false when paused.\n * Templates that play HTML5 <video> elements should pause them when this is false.\n * Undefined (export capture path) is treated as true.\n */\n isPlaying?: boolean;\n}\n"
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
"meta": {
|
|
56
|
+
"vanillasky": {
|
|
57
|
+
"layer": "template",
|
|
58
|
+
"tier": "free",
|
|
59
|
+
"register": "card-led",
|
|
60
|
+
"jobs": [
|
|
61
|
+
"claim",
|
|
62
|
+
"proof"
|
|
63
|
+
],
|
|
64
|
+
"useWhen": "A grounded public announcement, founder update, launch quote, or concise social claim should read as a post.",
|
|
65
|
+
"textCanvas": "open",
|
|
66
|
+
"minDuration": 2.5,
|
|
67
|
+
"preferredDuration": 3.5,
|
|
68
|
+
"schema": {
|
|
69
|
+
"type": "object",
|
|
70
|
+
"properties": {
|
|
71
|
+
"authorName": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"title": "Author name",
|
|
74
|
+
"description": "Display name shown bold next to the verified checkmark.",
|
|
75
|
+
"default": "Your brand"
|
|
76
|
+
},
|
|
77
|
+
"authorHandle": {
|
|
78
|
+
"type": "string",
|
|
79
|
+
"title": "Handle",
|
|
80
|
+
"description": "Username in @format. Leave empty to auto-derive from author name (e.g. 'Linear' → '@linear').",
|
|
81
|
+
"default": ""
|
|
82
|
+
},
|
|
83
|
+
"authorVerified": {
|
|
84
|
+
"type": "boolean",
|
|
85
|
+
"title": "Verified checkmark",
|
|
86
|
+
"description": "Show the blue verified checkmark next to the author name.",
|
|
87
|
+
"examples": [
|
|
88
|
+
true
|
|
89
|
+
],
|
|
90
|
+
"default": false
|
|
91
|
+
},
|
|
92
|
+
"message": {
|
|
93
|
+
"type": "string",
|
|
94
|
+
"title": "Tweet text",
|
|
95
|
+
"description": "The tweet/post content. Reads best at 1-3 lines (~80-180 chars). Emoji land naturally at the end of a phrase.",
|
|
96
|
+
"default": "Just shipped the new feature 🚀"
|
|
97
|
+
},
|
|
98
|
+
"likes": {
|
|
99
|
+
"type": "number",
|
|
100
|
+
"title": "Likes",
|
|
101
|
+
"description": "Target like count (rolls up from 0).",
|
|
102
|
+
"examples": [
|
|
103
|
+
602
|
|
104
|
+
],
|
|
105
|
+
"default": 0
|
|
106
|
+
},
|
|
107
|
+
"replies": {
|
|
108
|
+
"type": "number",
|
|
109
|
+
"title": "Replies",
|
|
110
|
+
"description": "Target reply count (rolls up from 0).",
|
|
111
|
+
"examples": [
|
|
112
|
+
19
|
|
113
|
+
],
|
|
114
|
+
"default": 0
|
|
115
|
+
},
|
|
116
|
+
"mediaUrl": {
|
|
117
|
+
"type": "string",
|
|
118
|
+
"title": "Background media",
|
|
119
|
+
"description": "Optional photo or video URL behind this scene. When set, replaces the brand gradient.",
|
|
120
|
+
"format": "uri",
|
|
121
|
+
"default": ""
|
|
122
|
+
},
|
|
123
|
+
"mediaKeyword": {
|
|
124
|
+
"type": "string",
|
|
125
|
+
"title": "Background search keyword",
|
|
126
|
+
"description": "2-4 word English term for Pexels stock-footage search (auto-fills mediaUrl).",
|
|
127
|
+
"format": "stock-media-keyword",
|
|
128
|
+
"default": ""
|
|
129
|
+
},
|
|
130
|
+
"mediaType": {
|
|
131
|
+
"type": "string",
|
|
132
|
+
"title": "Background media type",
|
|
133
|
+
"description": "auto detects photo/video from URL. 'gradient' is a deliberate mode — atmospheric brand-color scene with no stock footage.",
|
|
134
|
+
"enum": [
|
|
135
|
+
"auto",
|
|
136
|
+
"photo",
|
|
137
|
+
"video",
|
|
138
|
+
"gradient"
|
|
139
|
+
],
|
|
140
|
+
"default": "auto"
|
|
141
|
+
},
|
|
142
|
+
"mediaPoster": {
|
|
143
|
+
"type": "string",
|
|
144
|
+
"title": "Background poster image",
|
|
145
|
+
"description": "Still image URL shown while a video backdrop is decoding its first frame. Auto-filled from Pexels' thumbnail when fillPexelsUrls sets a video mediaUrl. Hides the gradient flash that would otherwise appear in the ~50–400ms gap between a <video> mounting and decoding its first frame.",
|
|
146
|
+
"format": "uri",
|
|
147
|
+
"default": ""
|
|
148
|
+
},
|
|
149
|
+
"mediaPosition": {
|
|
150
|
+
"type": "string",
|
|
151
|
+
"title": "Background focal position",
|
|
152
|
+
"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.",
|
|
153
|
+
"enum": [
|
|
154
|
+
"center",
|
|
155
|
+
"top",
|
|
156
|
+
"bottom",
|
|
157
|
+
"left",
|
|
158
|
+
"right"
|
|
159
|
+
],
|
|
160
|
+
"default": "center"
|
|
161
|
+
},
|
|
162
|
+
"mediaTreatment": {
|
|
163
|
+
"type": "string",
|
|
164
|
+
"title": "Background contrast treatment",
|
|
165
|
+
"description": "subtle preserves a visual hero; cinematic adds balanced contrast; text-safe adds a stronger wash for copy-heavy scenes.",
|
|
166
|
+
"enum": [
|
|
167
|
+
"subtle",
|
|
168
|
+
"cinematic",
|
|
169
|
+
"text-safe"
|
|
170
|
+
],
|
|
171
|
+
"default": "cinematic"
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
"required": [
|
|
175
|
+
"authorName",
|
|
176
|
+
"message"
|
|
177
|
+
],
|
|
178
|
+
"additionalProperties": false,
|
|
179
|
+
"x-vanillasky": {
|
|
180
|
+
"allowsStockMedia": true
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
"label": "X post",
|
|
184
|
+
"description": "An X-style text post card with author, handle, verification, replies, and likes.",
|
|
185
|
+
"family": "Social & messaging",
|
|
186
|
+
"avoidWhen": "The copy is generic explanatory prose, attribution is missing, or visual attachments are essential.",
|
|
187
|
+
"usesGlobalTextEffect": false,
|
|
188
|
+
"usesGlobalTransition": true,
|
|
189
|
+
"usesGlobalBackgroundEffect": true,
|
|
190
|
+
"timing": {
|
|
191
|
+
"contentFields": [
|
|
192
|
+
"authorName",
|
|
193
|
+
"message"
|
|
194
|
+
],
|
|
195
|
+
"contentUnit": "words"
|
|
196
|
+
},
|
|
197
|
+
"transitionTiming": {
|
|
198
|
+
"entryReadyProgress": 0.2,
|
|
199
|
+
"holdProgress": 0.7
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "typography",
|
|
4
|
+
"type": "registry:lib",
|
|
5
|
+
"title": "Typography",
|
|
6
|
+
"description": "Fit text to a fixed frame and give it a complete kinetic lifecycle.",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"@vanillasky/motion"
|
|
9
|
+
],
|
|
10
|
+
"files": [
|
|
11
|
+
{
|
|
12
|
+
"path": "src/visual-system/typography/index.ts",
|
|
13
|
+
"type": "registry:lib",
|
|
14
|
+
"target": "vanillasky/typography/index.ts",
|
|
15
|
+
"content": "/** Text fitting, formatting, timing, and kinetic type lifecycles. */\nexport * from \"../scene-templates/text-utils\";\nexport * from \"../scene-templates/text-archetypes\";\n"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "src/visual-system/scene-templates/text-archetypes.ts",
|
|
19
|
+
"type": "registry:lib",
|
|
20
|
+
"target": "vanillasky/scene-templates/text-archetypes.ts",
|
|
21
|
+
"content": "/**\n * Text archetypes — 7 complete lifecycles (entrance + hold + exit).\n *\n * Each archetype is a single named effect that owns its full lifecycle: how\n * text arrives, how it sits, how it leaves. The AI picks one name and that's\n * the whole package.\n *\n * Motion design principles applied per archetype:\n * - Distinct easing per phase (entrance ≠ exit, archetype-tuned)\n * - Anticipation / overshoot where the role calls for it\n * - Hold-phase breathing — no dead frames, ever\n * - Compound motion — multiple properties (scale, opacity, translate,\n * letter-spacing) move together with subtle offsets\n * - Exits are absolute-timed (in seconds), not a fixed % of scene, so\n * motion stays readable on long scenes too\n *\n * Filter:blur is unavailable (template determinism), so depth illusions are\n * carried by scale magnitude + letter-spacing + opacity ramp shape.\n */\n\nimport {\n interpolate,\n spring,\n Easing,\n SPRING_CRISP,\n} from \"../motion\";\n\n// ─── Archetype names ────────────────────────────────────────────\n\nexport const TEXT_ARCHETYPES = [\n \"subtle\",\n \"typewriter\",\n \"wordStagger\",\n \"slam\",\n \"cinematic\",\n \"heroWord\",\n] as const;\n\nexport type TextArchetype = (typeof TEXT_ARCHETYPES)[number];\nexport type TextCanvas = \"tight\" | \"open\";\nexport type TextRole = \"hook\" | \"body\" | \"closer\";\n\n/**\n * Normalize an input to a valid TextArchetype. Handles:\n * - undefined / null / empty → \"subtle\"\n * - already-valid archetype name → unchanged\n * - unknown string → \"subtle\" fallback\n */\nexport function normalizeArchetype(input: unknown): TextArchetype {\n if (typeof input !== \"string\" || input.length === 0) return \"subtle\";\n if ((TEXT_ARCHETYPES as readonly string[]).includes(input)) return input as TextArchetype;\n return \"subtle\";\n}\n\n// ─── Spec data ──────────────────────────────────────────────────\n\nexport interface ArchetypeSpec {\n name: TextArchetype;\n description: string;\n /** Entrance phase duration in seconds. */\n entrance: number;\n /** Exit phase duration in seconds. */\n exit: number;\n /** Where this archetype is allowed to run. */\n allowedCanvas: TextCanvas[];\n /** Suggested role(s) in the video arc. */\n roles: TextRole[];\n /** Font-size multiplier envelope vs. the slot's natural body size. */\n sizeRange: [number, number];\n /**\n * Minimum DURATION the effect itself needs (seconds), with SHORT text.\n * For text-dependent archetypes (typewriter, wordStagger, heroWord) the\n * actual minimum scales with content length — use `minDurationFor()` for\n * an accurate per-text computation.\n */\n minDuration: number;\n /**\n * Maximum DURATION the effect itself uses (seconds). On a longer scene,\n * the effect runs for at most this long, then disappears — leaving the\n * remaining scene time for the AI to fill with another effect. Prevents\n * \"ages to build up\" on long scenes.\n *\n * If text is so long that `minDurationFor()` exceeds this value, the\n * minimum wins (text needs the time).\n */\n maxDuration: number;\n}\n\n// Timings tuned to research findings (Material 3 motion tokens, AE motion-design\n// conventions, \"exits faster than entrances by 60–75%\" rule). Values target\n// 2.5s scene by default; phase math scales for longer scenes.\nexport const ARCHETYPE_SPECS: Record<TextArchetype, ArchetypeSpec> = {\n subtle: {\n name: \"subtle\",\n description: \"Quiet caption. Visual is the star, text supports.\",\n entrance: 0.5,\n exit: 0.7,\n allowedCanvas: [\"tight\", \"open\"],\n roles: [\"body\", \"closer\"],\n sizeRange: [0.7, 1.0],\n minDuration: 1.5,\n maxDuration: 5,\n },\n typewriter: {\n name: \"typewriter\",\n description: \"Char-by-char reveal with blinking cursor. Slide-left cascade exit, top line first.\",\n entrance: 1.2,\n exit: 0.85,\n allowedCanvas: [\"tight\", \"open\"],\n roles: [\"body\", \"hook\"],\n sizeRange: [0.8, 1.0],\n minDuration: 1.8,\n maxDuration: 6,\n },\n wordStagger: {\n name: \"wordStagger\",\n description: \"Sequential word build with active-word focus. Slide-left cascade exit, top line first.\",\n entrance: 1.4,\n exit: 0.7,\n allowedCanvas: [\"tight\", \"open\"],\n roles: [\"body\"],\n sizeRange: [0.8, 1.0],\n minDuration: 1.8,\n maxDuration: 6,\n },\n slam: {\n name: \"slam\",\n description: \"In-place impact with violent squash-and-stretch, frame shake, and letter crunch.\",\n entrance: 0.5,\n exit: 0.5,\n allowedCanvas: [\"tight\", \"open\"],\n roles: [\"hook\", \"body\"],\n sizeRange: [0.9, 1.2],\n minDuration: 1.2,\n maxDuration: 4,\n },\n cinematic: {\n name: \"cinematic\",\n description: \"Trailer FlyIn from depth → recedes back into background.\",\n entrance: 0.7,\n exit: 0.8,\n allowedCanvas: [\"open\"],\n roles: [\"hook\", \"body\", \"closer\"],\n sizeRange: [0.9, 1.1],\n minDuration: 1.8,\n maxDuration: 6,\n },\n heroWord: {\n name: \"heroWord\",\n description: \"Single word fills frame. Simple fast exit so words get the most time.\",\n entrance: 0.35,\n exit: 0.25,\n allowedCanvas: [\"open\"],\n roles: [\"hook\", \"body\", \"closer\"],\n sizeRange: [2.0, 3.0],\n minDuration: 1.0,\n maxDuration: 5,\n },\n};\n\n// ─── Lifecycle constants ────────────────────────────────────────\n\nconst ENTRANCE_START = 0.02;\n// Phase caps prevent very short scenes from making in/out exceed the scene.\nconst ENTRANCE_PHASE_CAP = 0.4;\nconst EXIT_PHASE_CAP = 0.4;\n\n// ─── Render output shapes ───────────────────────────────────────\n\nexport interface BlockStyle {\n opacity: number;\n transform: string;\n letterSpacing?: string;\n /**\n * `willChange` hint to keep the layer GPU-composited during animation.\n * Used by cinematic where the scale-collapse needs to stay smooth on\n * mobile Safari (which otherwise downgrades the layer mid-animation).\n */\n willChange?: string;\n}\n\nexport interface PerWordStyle {\n text: string;\n style: BlockStyle;\n}\n\nexport type ArchetypeRender =\n | { kind: \"block\"; block: BlockStyle; text: string }\n | {\n kind: \"typewriter\";\n visibleChars: number;\n cursor: boolean;\n opacity: number;\n transform: string;\n /**\n * Per-character exit overrides — present during the exit phase to\n * drive a slide-left cascade in reading order. One entry per char\n * in `text`. Undefined during entrance/hold.\n */\n charExits?: { opacity: number; translateX: number }[];\n }\n | {\n kind: \"words\";\n words: PerWordStyle[];\n blockOpacity: number;\n blockTransform: string;\n }\n | {\n kind: \"hero\";\n word: string;\n index: number;\n total: number;\n opacity: number;\n transform: string;\n letterSpacing?: string;\n };\n\n// ─── Phase + timing helpers ─────────────────────────────────────\n\ninterface Phases {\n entrancePhase: number;\n exitPhase: number;\n exitStart: number;\n inExit: boolean;\n}\n\nfunction computePhases(\n spec: ArchetypeSpec,\n sceneDuration: number,\n progress: number,\n phaseScale = 1,\n): Phases {\n // `phaseScale` is style.motion (calm 1.4 / normal 1 / punchy 0.7). It\n // stretches or compresses how much of the scene the entrance and exit\n // occupy; the caps below still bound them, so a calm video never spends\n // the whole scene animating in.\n const entrancePhase = Math.min(ENTRANCE_PHASE_CAP, (spec.entrance * phaseScale) / sceneDuration);\n const exitPhase = Math.min(EXIT_PHASE_CAP, (spec.exit * phaseScale) / sceneDuration);\n const exitStart = 1 - exitPhase;\n return { entrancePhase, exitPhase, exitStart, inExit: progress >= exitStart };\n}\n\nfunction rawEntrance(progress: number, entrancePhase: number, startAt = ENTRANCE_START): number {\n return interpolate(progress, [startAt, startAt + entrancePhase], [0, 1], {\n extrapolateLeft: \"clamp\",\n extrapolateRight: \"clamp\",\n });\n}\n\nfunction rawExit(progress: number, exitStart: number): number {\n if (progress < exitStart) return 0;\n return interpolate(progress, [exitStart, 1], [0, 1], {\n extrapolateLeft: \"clamp\",\n extrapolateRight: \"clamp\",\n });\n}\n\n// Normalized hold time 0→1 over the hold phase; 0 outside.\nfunction holdT(\n progress: number,\n entrancePhase: number,\n exitStart: number,\n startAt = ENTRANCE_START,\n): number {\n const holdStart = startAt + entrancePhase;\n if (progress < holdStart || progress > exitStart || exitStart <= holdStart) return 0;\n return (progress - holdStart) / (exitStart - holdStart);\n}\n\n// Sine breathe — returns offset around 0; magnitude is peak-to-peak.\nfunction breathe(t: number, magnitude: number, cycles = 1): number {\n return (Math.sin(t * Math.PI * 2 * cycles) * magnitude) / 2;\n}\n\n// ─── Renderer ───────────────────────────────────────────────────\n\nexport function renderArchetype(\n archetypeRaw: TextArchetype | string | undefined,\n progress: number,\n scale: number,\n text: string,\n sceneDuration: number,\n /** style.motion's phase multiplier. 1 (the default) is the authored timing. */\n phaseScale = 1,\n /** Optional transition-safe clock for entrance/exit motion only. */\n motionProgress = progress,\n): ArchetypeRender {\n const archetype = normalizeArchetype(archetypeRaw);\n const spec = ARCHETYPE_SPECS[archetype];\n const phases = computePhases(spec, sceneDuration, motionProgress, phaseScale);\n // Semantic reveals stay on raw time even if a custom host supplies a\n // different presentation clock. VideoFrame itself preserves the same\n // complete 0→1 timeline for both clocks.\n const presentationProgress = Math.max(progress, motionProgress);\n\n switch (archetype) {\n case \"subtle\":\n return renderSubtle(motionProgress, scale, text, phases);\n case \"typewriter\":\n return renderTypewriter(presentationProgress, motionProgress, scale, text, phases, sceneDuration);\n case \"wordStagger\":\n return renderWordStagger(presentationProgress, motionProgress, scale, text, sceneDuration);\n case \"slam\":\n return renderSlam(motionProgress, scale, text, phases);\n case \"cinematic\":\n return renderCinematic(motionProgress, scale, text, phases);\n case \"heroWord\":\n return renderHeroWord(presentationProgress, motionProgress, text, phases);\n }\n}\n\n// ─── subtle ─────────────────────────────────────────────────────\n// Pure fade in / fade out — true to the name. No translation, no breathe,\n// no transition jump. The supporting caption that doesn't compete with the\n// visual. Only thing animating is opacity.\nfunction renderSubtle(\n progress: number,\n _scale: number,\n text: string,\n phases: Phases,\n): ArchetypeRender {\n const raw = rawEntrance(progress, phases.entrancePhase);\n const ex = rawExit(progress, phases.exitStart);\n\n const opacityIn = Easing.out(Easing.cubic)(raw);\n const opacityOut = 1 - Easing.in(Easing.cubic)(ex);\n const opacity = opacityIn * (phases.inExit ? opacityOut : 1);\n\n return {\n kind: \"block\",\n block: {\n opacity,\n transform: \"none\",\n },\n text,\n };\n}\n\n// ─── typewriter ─────────────────────────────────────────────────\n// Char reveal with deterministic cursor blink. Exit slides each char off\n// the left edge in reading order — same cascade pattern as wordStagger\n// (top line clears first by virtue of reading-order index ascending).\nfunction renderTypewriter(\n progress: number,\n motionProgress: number,\n scale: number,\n text: string,\n phases: Phases,\n sceneDuration: number,\n): ArchetypeRender {\n const raw = rawEntrance(progress, phases.entrancePhase);\n\n // Constant typing pace — linear, no easing. Decelerating eases make the\n // last chars feel sluggish, which kills the typewriter rhythm.\n const visibleChars = Math.max(0, Math.floor(raw * text.length));\n\n // Time-based 1.5Hz blink (OS terminal convention). Stays consistent\n // regardless of scene length.\n const seconds = progress * sceneDuration;\n const cursorOn = Math.sin(seconds * Math.PI * 3) > 0;\n const stillTyping = visibleChars < text.length;\n\n // Entry opacity ramps fast (we want chars solid as they appear).\n const opacityIn = Math.min(1, raw * 8);\n\n // Cursor visible during typing and the tail of the hold; gone on exit.\n const cursor = cursorOn && (stillTyping || (!phases.inExit && opacityIn > 0.5));\n\n // Per-character exit cascade — slides each char left, staggered by\n // reading-order index. Compresses if the exit window can't fit the full\n // sequence (very long text on a short scene).\n let charExits: { opacity: number; translateX: number }[] | undefined;\n if (phases.inExit && text.length > 0) {\n const STAGGER_OUT = 0.025;\n const PER_CHAR_OUT = 0.30;\n const SLIDE_LEFT_DISTANCE = 350 * scale;\n const actualExitSec = phases.exitPhase * sceneDuration;\n const idealExitSec = (text.length - 1) * STAGGER_OUT + PER_CHAR_OUT;\n const outScale = Math.min(1, actualExitSec / idealExitSec);\n const stagOutNorm = (STAGGER_OUT * outScale) / sceneDuration;\n const perCharOutNorm = (PER_CHAR_OUT * outScale) / sceneDuration;\n\n charExits = Array.from({ length: text.length }, (_, i) => {\n const charExitStart = phases.exitStart + i * stagOutNorm;\n const charExitEnd = charExitStart + perCharOutNorm;\n const charExitRaw = interpolate(motionProgress, [charExitStart, charExitEnd], [0, 1], {\n extrapolateLeft: \"clamp\",\n extrapolateRight: \"clamp\",\n });\n const charExitEased = Easing.in(Easing.cubic)(charExitRaw);\n return {\n opacity: 1 - charExitEased,\n translateX: -charExitEased * SLIDE_LEFT_DISTANCE,\n };\n });\n }\n\n return {\n kind: \"typewriter\",\n visibleChars,\n cursor,\n opacity: opacityIn,\n transform: \"none\",\n charExits,\n };\n}\n\n// ─── wordStagger ────────────────────────────────────────────────\n// Dynamic entrance: total time depends on word count (more words = more\n// time to read each one), bounded so it doesn't run forever on long scenes\n// or get crammed on short ones. As the NEXT word arrives, prior words dim\n// to ~45% opacity — the kinetic-typography \"active word\" focus.\n// Exit slides each word off the left edge in reading order. Words wrap via\n// CSS so we never know line breaks at render time, but indices ARE in\n// reading order — staggering by index gives a top-line-first cascade for\n// free: words on line 1 occupy the lowest indices, so they exit before\n// any word on line 2.\nfunction renderWordStagger(\n progress: number,\n motionProgress: number,\n scale: number,\n text: string,\n sceneDuration: number,\n): ArchetypeRender {\n const words = text.split(/\\s+/).filter(Boolean);\n const wordCount = Math.max(words.length, 1);\n\n // Per-word timing constants (seconds). Min/max stagger keeps the cadence\n // in the Hollywood-typography sweet spot (~280–450ms per word land)\n // regardless of scene length.\n const STAGGER_MIN = 0.28;\n const STAGGER_MAX = 0.45;\n const PER_WORD_ANIM = 0.5;\n\n // Floor entrance using the minimum stagger.\n const minEntrance = (wordCount - 1) * STAGGER_MIN + PER_WORD_ANIM;\n\n // Target: ~55% of the scene for entrance, so longer scenes get a longer\n // build-up. Without this the entrance feels rushed against the long hold.\n const TARGET_ENTRANCE_FRACTION = 0.55;\n const targetEntrance = sceneDuration * TARGET_ENTRANCE_FRACTION;\n\n // Ceiling: leave room for exit + meaningful hold so the FINAL word sits\n // as the focal point.\n const exitSec = ARCHETYPE_SPECS.wordStagger.exit;\n const minHold = 0.5;\n const maxEntrance = Math.max(0.6, sceneDuration - exitSec - minHold);\n\n // Pick proportional target, bounded by [minEntrance, maxEntrance].\n let entranceSec = Math.min(maxEntrance, Math.max(minEntrance, targetEntrance));\n\n // Cap the implied per-word stagger at STAGGER_MAX so words don't sit too\n // long between lands on very long scenes.\n if (wordCount > 1) {\n const impliedStagger = (entranceSec - PER_WORD_ANIM) / (wordCount - 1);\n if (impliedStagger > STAGGER_MAX) {\n entranceSec = (wordCount - 1) * STAGGER_MAX + PER_WORD_ANIM;\n }\n }\n\n // Recompute phases for this archetype (overrides default phases).\n const entrancePhase = entranceSec / sceneDuration;\n const exitPhase = Math.min(0.4, exitSec / sceneDuration);\n const exitStart = 1 - exitPhase;\n\n // Word entrance window: each word's own animation gets PER_WORD_ANIM\n // seconds, the rest of entrancePhase is the staggered offset distribution.\n const staggerIn =\n wordCount > 1 ? (entranceSec - PER_WORD_ANIM) / (wordCount - 1) / sceneDuration : 0;\n const wordEntranceDur = PER_WORD_ANIM / sceneDuration;\n\n // Per-word exit cadence. STAGGER_OUT is short so words within a single\n // wrapped line clear nearly together — the perceived gap between lines\n // comes from the words *between* line breaks, not the per-word delay.\n // Compress proportionally if the scene's exit window can't fit the full\n // cascade (e.g. very short scene with many words).\n const STAGGER_OUT = 0.06;\n const PER_WORD_OUT = 0.32;\n const SLIDE_LEFT_DISTANCE = 350 * scale;\n const actualExitSec = exitPhase * sceneDuration;\n const idealExitSec = (wordCount - 1) * STAGGER_OUT + PER_WORD_OUT;\n const outScale = Math.min(1, actualExitSec / idealExitSec);\n const stagOutNorm = (STAGGER_OUT * outScale) / sceneDuration;\n const perWordOutNorm = (PER_WORD_OUT * outScale) / sceneDuration;\n\n const ht = holdT(motionProgress, entrancePhase, exitStart);\n\n // Active-word focus: previous words dim to ACTIVE_DIM as the next word\n // arrives — reads as a \"grey\" handoff via opacity reduction, directs\n // attention to the latest word without making prior ones unreadable.\n // Bumped from 0.70 → 0.85: 70% became muddy on busy photo backgrounds\n // (especially mid-tone areas where the dimmed white blends into the bg).\n // 85% keeps the active-word hierarchy while staying legible on media.\n const ACTIVE_DIM = 0.85;\n\n const perWord = words.map((w, i) => {\n // Entrance window for THIS word\n const wordStart = ENTRANCE_START + i * staggerIn;\n const wordEnd = wordStart + wordEntranceDur;\n const wordRaw = interpolate(progress, [wordStart, wordEnd], [0, 1], {\n extrapolateLeft: \"clamp\",\n extrapolateRight: \"clamp\",\n });\n const sp = spring(wordRaw, SPRING_CRISP);\n const yIn = interpolate(sp, [0, 1], [55 * scale, 0]);\n const rotIn = interpolate(sp, [0, 1], [-3, 0]);\n const opacityIn = Math.min(1, wordRaw * 7);\n\n // Subtle hold breathe (won't even register but keeps each word \"alive\")\n const yBreathe = breathe(ht, 1 * scale, 1) * Math.cos((i / wordCount) * Math.PI);\n\n // Dim instantly when the NEXT word starts entering — tiny 50ms ramp so\n // the transition isn't a hard cut, but visually reads as \"snaps to grey\n // the moment the new word appears.\"\n let dimProgress = 0;\n if (i < words.length - 1) {\n const nextStart = ENTRANCE_START + (i + 1) * staggerIn;\n const SNAP_RAMP = 0.02; // ~50ms at 2.5s scene\n dimProgress = interpolate(progress, [nextStart, nextStart + SNAP_RAMP], [0, 1], {\n extrapolateLeft: \"clamp\",\n extrapolateRight: \"clamp\",\n });\n }\n const focusOpacity = 1 - dimProgress * (1 - ACTIVE_DIM);\n\n // Per-word exit: each word starts sliding left at exitStart + i*stagOut,\n // accelerates with ease-in-cubic so it feels yanked off-frame.\n const wordExitStart = exitStart + i * stagOutNorm;\n const wordExitEnd = wordExitStart + perWordOutNorm;\n const wordExitRaw = interpolate(motionProgress, [wordExitStart, wordExitEnd], [0, 1], {\n extrapolateLeft: \"clamp\",\n extrapolateRight: \"clamp\",\n });\n const wordExitEased = Easing.in(Easing.cubic)(wordExitRaw);\n const exitX = -wordExitEased * SLIDE_LEFT_DISTANCE;\n const isExiting = wordExitRaw > 0;\n\n // Final composition\n const opacity = opacityIn * focusOpacity * (1 - wordExitEased);\n const y = yIn + (isExiting ? 0 : yBreathe);\n const rot = isExiting ? 0 : rotIn;\n\n return {\n text: w,\n style: {\n opacity,\n transform: `translate(${exitX}px, ${y}px) rotate(${rot}deg)`,\n },\n };\n });\n\n return {\n kind: \"words\",\n words: perWord,\n blockOpacity: 1,\n blockTransform: \"none\",\n };\n}\n\n// ─── slam ──────────────────────────────────────────────────\n// In-place impact, NOT a depth/zoom motion (cinematic already owns that\n// lane). Word appears at readable size and HITS — drama lives in the\n// squash-and-stretch arc:\n// approach pose (vertically stretched, scaleY > 1, scaleX < 1)\n// → impact (violent horizontal pancake, scaleX 1.22 / scaleY 0.74,\n// frame shake + letter crunch firing simultaneously)\n// → rebound (counter-stretched scaleY 1.12 / scaleX 0.94)\n// → damped settle to 1.0\n// No vertical translation — the squash arc + camera shake + tilt kick\n// carry the impact without any top-to-bottom drop.\nfunction renderSlam(\n progress: number,\n scale: number,\n text: string,\n phases: Phases,\n): ArchetypeRender {\n const raw = rawEntrance(progress, phases.entrancePhase);\n const ex = rawExit(progress, phases.exitStart);\n const ht = holdT(progress, phases.entrancePhase, phases.exitStart);\n\n // Squash-and-stretch arc. Keyframes (raw): 0 = approach pose, 0.30 =\n // impact, 0.45 = rebound, 0.60 = secondary oscillation, 1.0 = rest.\n // scaleX/scaleY are anti-correlated at every keyframe — that's what\n // sells \"weight\" vs. cinematic's uniform scale collapse.\n const sxIn = interpolate(raw, [0, 0.30, 0.45, 0.60, 1.0], [0.92, 1.22, 0.94, 1.02, 1.0]);\n const syIn = interpolate(raw, [0, 0.30, 0.45, 0.60, 1.0], [1.18, 0.74, 1.12, 0.99, 1.0]);\n // Tilt-and-correct: small entry tilt, levels at impact, micro overshoot.\n const rotIn = interpolate(raw, [0, 0.30, 0.45, 1.0], [-1.5, 0, 0.8, 0]);\n // Transient letter crunch AT impact, releases open by rebound. Distinct\n // from cinematic's wide→tight gradual tracking collapse.\n const lsInEm = interpolate(raw, [0, 0.20, 0.30, 0.45, 1.0], [0, -0.04, -0.06, 0, 0]);\n\n // Camera shake — full intensity at impact, fast decay over the next 20%\n // of entrance. Sin/cos at offset frequencies feel noisy while staying\n // deterministic for export.\n const shakeWindow = Math.max(0, 1 - Math.max(0, raw - 0.30) / 0.20);\n const shakeX = Math.sin(raw * 70) * 14 * scale * shakeWindow;\n const shakeY = Math.cos(raw * 80) * 10 * scale * shakeWindow;\n\n // Punchy opacity ramp so text is solid through the size change.\n const opacityIn = Math.min(1, raw * 8);\n\n // Hold: gentle 0.8% uniform scale breathe.\n const sBreathe = 1 + breathe(ht, 0.008, 1);\n\n // Exit: ease-in-expo shrink + late-bias fade — decisive leave.\n const easedExit = Easing.in(Easing.exp)(ex);\n const sOut = interpolate(easedExit, [0, 1], [1, 0.85]);\n const opacityOut = interpolate(ex, [0, 0.55, 1], [1, 0.8, 0], {\n extrapolateLeft: \"clamp\",\n extrapolateRight: \"clamp\",\n });\n\n const sx = phases.inExit ? sOut : sxIn * sBreathe;\n const sy = phases.inExit ? sOut : syIn * sBreathe;\n const tx = phases.inExit ? 0 : shakeX;\n const ty = phases.inExit ? 0 : shakeY;\n const rot = phases.inExit ? 0 : rotIn;\n const ls = phases.inExit ? 0 : lsInEm;\n const opacity = opacityIn * (phases.inExit ? opacityOut : 1);\n\n return {\n kind: \"block\",\n block: {\n opacity,\n transform: `translate(${tx}px, ${ty}px) rotate(${rot}deg) scale(${sx}, ${sy})`,\n letterSpacing: `${ls}em`,\n },\n text,\n };\n}\n\n// ─── cinematic ──────────────────────────────────────────────────\n// Hollywood Movie-Trailer FlyIn (vuild-style without filter:blur).\n// Entry: ease-out-circ — weighty deceleration, classic cinema arrival.\n// Scale 3.0 → 1.0, tracking +40 → 0 (letters wide-spaced from depth\n// collapse to readable). Slow opacity ramp finishes by progress 0.4\n// so text becomes \"real\" as it nears the camera plane.\n// Hold: ±0.6px tracking wobble + 1% scale breathe.\n// Exit: ease-in-expo — text holds, then RECEDES into the background.\n// Scale 1.0 → 0.4 (zooms out / shrinks away), tracking spreads back\n// out (0 → +24) like objects dissipating into the distance, opacity\n// holds high then drops late so the recede motion stays visible.\nfunction renderCinematic(\n progress: number,\n scale: number,\n text: string,\n phases: Phases,\n): ArchetypeRender {\n const raw = rawEntrance(progress, phases.entrancePhase);\n const ex = rawExit(progress, phases.exitStart);\n\n // Entry: ease-out-circle — slow start (text far away), strong deceleration into place\n const easedIn = Easing.out(Easing.circle)(raw);\n const sIn = interpolate(easedIn, [0, 1], [3.0, 1]);\n const yIn = interpolate(easedIn, [0, 1], [18 * scale, 0]);\n // Slow opacity ramp — text becomes \"real\" only as it approaches the camera\n const opacityIn = interpolate(raw, [0, 0.4], [0, 1], {\n extrapolateLeft: \"clamp\",\n extrapolateRight: \"clamp\",\n });\n\n // No hold-phase breathing — subpixel rendering on tiny scale changes\n // reads as jitter on stable text. Cinematic holds dead-still during the\n // visible phase.\n\n // Exit: ease-in-expo — held, then RECEDES into the background\n const easedExit = Easing.in(Easing.exp)(ex);\n const sOut = interpolate(easedExit, [0, 1], [1, 0.4]);\n // Subtle drift back/up to reinforce \"going away\"\n const yOut = interpolate(easedExit, [0, 1], [0, -10 * scale]);\n // Opacity holds 100% for first 50% of exit, then drops to 0\n const opacityOut = interpolate(ex, [0, 0.5, 1], [1, 0.85, 0], {\n extrapolateLeft: \"clamp\",\n extrapolateRight: \"clamp\",\n });\n\n const s = phases.inExit ? sOut : sIn;\n const y = phases.inExit ? yOut : yIn;\n const opacity = opacityIn * (phases.inExit ? opacityOut : 1);\n\n // No CSS filter. Earlier iterations layered a ~2.5px gaussian blur at the\n // lifecycle edges for a Hollywood motion-blur feel, but iOS Safari is\n // notoriously bad at compositing `filter` with an animating `transform` —\n // the zoom would stall mid-motion on mobile while desktop and the\n // server-side Puppeteer export rendered fine. The cinematic effect's\n // identity is the scale collapse + slow opacity ramp + ease-out-circ\n // timing, all of which work without the filter. willChange hints both\n // properties are animating so the browser keeps the layer GPU-composited.\n return {\n kind: \"block\",\n block: {\n opacity,\n transform: `translateY(${y}px) scale(${s})`,\n willChange: \"transform, opacity\",\n },\n text,\n };\n}\n\n// ─── heroWord ───────────────────────────────────────────────────\n// One word at a time, each lands with anticipation overshoot. Mid-sequence\n// words hand off with brief brightness flash before fade. Final word's exit\n// participates in the global exit window — scales up + fades for the finale.\nfunction renderHeroWord(\n progress: number,\n motionProgress: number,\n text: string,\n phases: Phases,\n): ArchetypeRender {\n const words = text.split(/\\s+/).filter(Boolean);\n if (words.length === 0) {\n return { kind: \"hero\", word: \"\", index: 0, total: 0, opacity: 0, transform: \"scale(1)\" };\n }\n\n // Words occupy the time between entrance start and exit start.\n const sequenceStart = ENTRANCE_START + phases.entrancePhase * 0.2;\n const sequenceEnd = phases.exitStart;\n const sequenceDur = Math.max(0.01, sequenceEnd - sequenceStart);\n // Determine active word.\n const localProgress = Math.max(0, Math.min(0.999, (progress - sequenceStart) / sequenceDur));\n const idx = Math.min(words.length - 1, Math.floor(localProgress / (1 / words.length)));\n const local = (localProgress * words.length) - idx;\n const isLast = idx === words.length - 1;\n\n // Per-word entrance: anticipation + spring overshoot\n const ENTER_FRAC = 0.28;\n const HOLD_FRAC = 0.55;\n const HANDOFF_FRAC = 0.85;\n\n let scale: number;\n let opacity: number;\n\n if (local < ENTER_FRAC) {\n const entRaw = local / ENTER_FRAC;\n const sp = spring(entRaw, SPRING_CRISP);\n // Overshoot: 0.5 → 1.06 → 1.0\n scale = sp < 0.7 ? interpolate(sp, [0, 0.7], [0.5, 1.06]) : interpolate(sp, [0.7, 1], [1.06, 1.0]);\n opacity = Math.min(1, entRaw * 6);\n } else if (local < HOLD_FRAC) {\n // Hold: 0.5% breathe per word\n const ht = (local - ENTER_FRAC) / (HOLD_FRAC - ENTER_FRAC);\n scale = 1 + breathe(ht, 0.01, 1);\n opacity = 1;\n } else if (local < HANDOFF_FRAC && !isLast) {\n // Brief brightness pulse + fade for handoff to next word\n const handoffRaw = (local - HOLD_FRAC) / (HANDOFF_FRAC - HOLD_FRAC);\n scale = interpolate(handoffRaw, [0, 1], [1, 1.04]);\n // Pulse: 1 → 1.15 (brightness via opacity > 1 is faked by holding 1) → fade\n opacity = handoffRaw < 0.3 ? 1 : interpolate(handoffRaw, [0.3, 1], [1, 0]);\n } else if (!isLast) {\n // Trailing nothing-frame for non-last words\n scale = 1.04;\n opacity = 0;\n } else {\n // Last word: hold until global exit kicks in\n scale = 1;\n opacity = 1;\n }\n\n // Last word participates in the global exit — simple fast fade with a\n // tiny scale lift. No anticipation, no tracking compression: the goal is\n // to give the WORDS the most time, not the exit animation.\n if (isLast && phases.inExit) {\n const ex = rawExit(motionProgress, phases.exitStart);\n const easedExit = Easing.in(Easing.cubic)(ex);\n scale = interpolate(easedExit, [0, 1], [1, 1.08]);\n opacity = 1 - easedExit;\n }\n\n return {\n kind: \"hero\",\n word: words[idx],\n index: idx,\n total: words.length,\n opacity,\n transform: `scale(${scale})`,\n };\n}\n\n// ─── Effect duration helpers ────────────────────────────────────\n//\n// Per-archetype min/max DURATION (seconds) — the time the effect itself\n// uses, not the scene length. On long scenes the effect runs at most for\n// `maxDuration` and then disappears, signaling the AI to fill the rest\n// with another effect.\n//\n// - minDurationFor(archetype, text): minimum effect duration accounting\n// for text length (text-dependent archetypes scale up).\n// - effectiveDuration(archetype, text, sceneDuration): how long the\n// effect actually runs in the given scene — bounded by [minFor, max].\n// - canFit(items, sceneSeconds): whether a sequence of (archetype, text)\n// plays fits within a scene.\n\nconst TYPEWRITER_SEC_PER_CHAR = 0.08; // matches the linear char reveal pace\nconst HEROWORD_PER_WORD_SLOT = 0.6; // each word in heroWord needs ~0.6s on screen\nconst WORDSTAGGER_MIN_HOLD = 0.5; // matches renderWordStagger's minHold\n\n/**\n * Compute the minimum DURATION (seconds) the effect needs for this text.\n * Text-dependent archetypes scale up; others return their static\n * spec.minDuration.\n */\nexport function minDurationFor(archetype: TextArchetype, text: string): number {\n const spec = ARCHETYPE_SPECS[archetype];\n const wordCount = Math.max(1, text.split(/\\s+/).filter(Boolean).length);\n\n switch (archetype) {\n case \"typewriter\": {\n const revealSec = Math.max(0.6, text.length * TYPEWRITER_SEC_PER_CHAR);\n return revealSec + 0.3 + spec.exit;\n }\n case \"wordStagger\": {\n return (wordCount - 1) * 0.28 + 0.5 + WORDSTAGGER_MIN_HOLD + spec.exit;\n }\n case \"heroWord\": {\n return wordCount * HEROWORD_PER_WORD_SLOT + spec.exit;\n }\n default:\n return spec.minDuration;\n }\n}\n\n/**\n * How long the effect actually runs inside a given scene. The effect uses\n * at most spec.maxDuration; if the scene is shorter than that, it uses\n * the scene length; if the text needs more (long typewriter, many words),\n * the minimum wins.\n *\n * On long scenes, sceneDuration > effectiveDuration(...) means the AI\n * has remaining time to fill with another effect.\n */\nexport function effectiveDuration(\n archetype: TextArchetype,\n text: string,\n sceneDuration: number,\n): number {\n const spec = ARCHETYPE_SPECS[archetype];\n const minFor = minDurationFor(archetype, text);\n const cappedMax = Math.max(spec.maxDuration, minFor);\n return Math.min(sceneDuration, cappedMax);\n}\n\nexport interface ArchetypePlay {\n archetype: TextArchetype;\n text: string;\n}\n\n/**\n * Whether a sequence of archetype plays fits in the given scene duration.\n * Used by the AI/tool layer to validate before committing to a plan.\n *\n * @example\n * canFit(\n * [\n * { archetype: \"subtle\", text: \"Hi\" },\n * { archetype: \"heroWord\", text: \"Launch\" },\n * ],\n * 4.0,\n * ); // → true if both fit; false otherwise\n */\nexport function canFit(items: ArchetypePlay[], sceneSeconds: number): boolean {\n const total = items.reduce(\n (sum, item) => sum + minDurationFor(item.archetype, item.text),\n 0,\n );\n return total <= sceneSeconds;\n}\n\n/**\n * Total minimum seconds for a sequence — useful when canFit returns false\n * and you need to know how much time is missing.\n */\nexport function totalMinDurationFor(items: ArchetypePlay[]): number {\n return items.reduce(\n (sum, item) => sum + minDurationFor(item.archetype, item.text),\n 0,\n );\n}\n"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"path": "src/visual-system/scene-templates/text-utils.ts",
|
|
25
|
+
"type": "registry:lib",
|
|
26
|
+
"target": "vanillasky/scene-templates/text-utils.ts",
|
|
27
|
+
"content": "/**\n * Shared text utilities. Kept intentionally small.\n */\n\n/**\n * Return the first pipe-delimited text segment. Template variables use pipes\n * for explicit text segmentation. For values without `|` this is a no-op.\n */\nexport function stripPipe(text: string): string {\n const idx = text.indexOf(\"|\");\n return idx === -1 ? text : text.slice(0, idx);\n}\n\n/**\n * Responsive font size for caption-style text — used by chart-counter to\n * size its sub-label below the big number. Mirrors the prior helper from\n * the deleted text-entrance module so behavior is preserved.\n *\n * @param text the text to size\n * @param width render width (1080 portrait, smaller in preview)\n * @param role \"headline\" or \"subtitle\" — biases the curve\n * @param height optional height for proper short-edge scaling\n */\nexport function getResponsiveFontSize(\n text: string,\n width: number,\n role: \"headline\" | \"subtitle\" = \"headline\",\n height?: number,\n): number {\n const s = height ? Math.min(width, height) / 1080 : width / 1080;\n const words = text.split(/\\s+/).filter(Boolean).length;\n const chars = text.length;\n\n if (role === \"subtitle\") {\n if (chars > 60) return 24 * s;\n if (chars > 30) return 28 * s;\n return 32 * s;\n }\n\n if (words <= 2 && chars <= 12) return Math.max(36 * s, 96 * s);\n if (words <= 3 && chars <= 20) return Math.max(36 * s, 80 * s);\n if (words <= 5 && chars <= 35) return Math.max(36 * s, 64 * s);\n if (words <= 8 && chars <= 60) return Math.max(36 * s, 48 * s);\n return 36 * s;\n}\n\n/**\n * Shrink `baseFontSize` so that `text` rendered at that size fits within\n * `maxWidth` pixels on one line. Returns the original size if the text\n * already fits; otherwise scales proportionally down to `minScale × base`.\n *\n * Approach: estimate text width as `chars × fontSize × charWidthRatio`.\n * 0.55 is a safe over-estimate for Helvetica/Arial-family sans-serif at\n * normal weights — picks slightly smaller than strictly necessary so URLs\n * with wide caps (W, M) still fit. Override `charWidthRatio` for narrower\n * fonts (e.g. condensed) or wider ones (display).\n *\n * Used by templates with fixed-width containers that can overflow on long\n * AI-generated copy (URLs in cta-*, prompt text in promptInput, caller\n * name in incomingCall). Cheap, deterministic, no canvas measurement —\n * runs identically in renderToStaticMarkup and in the browser.\n */\nexport function fitTextSize(\n text: string,\n baseFontSize: number,\n maxWidth: number,\n opts?: { minScale?: number; charWidthRatio?: number },\n): number {\n if (!text) return baseFontSize;\n const minScale = opts?.minScale ?? 0.5;\n const charWidthRatio = opts?.charWidthRatio ?? 0.55;\n const estWidth = text.length * baseFontSize * charWidthRatio;\n if (estWidth <= maxWidth) return baseFontSize;\n const scaled = (maxWidth / estWidth) * baseFontSize;\n return Math.max(scaled, baseFontSize * minScale);\n}\n\n/**\n * Compact a number for social-style counters (likes, replies, views).\n * Match TweetCard.formatCount — kept in sync. See that function for the\n * boundary-rounding rationale (never let the leading number reach 4\n * digits; 999,999 rolls up to \"1M\", not \"1000K\").\n */\nexport function formatCompact(n: number): string {\n if (!Number.isFinite(n)) return String(n);\n const abs = Math.abs(n);\n const sign = n < 0 ? \"-\" : \"\";\n if (abs < 1_000) return `${sign}${Math.round(abs)}`;\n if (abs < 10_000) {\n const rounded = Number((abs / 1_000).toFixed(1));\n if (rounded < 10) return `${sign}${rounded.toString().replace(/\\.0$/, \"\")}K`;\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 }\n if (abs < 10_000_000) {\n const rounded = Number((abs / 1_000_000).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 return `${sign}${(abs / 1_000_000_000).toFixed(1).replace(/\\.0$/, \"\")}B`;\n}\n"
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"meta": {
|
|
31
|
+
"vanillasky": {
|
|
32
|
+
"layer": "lib",
|
|
33
|
+
"tier": "free",
|
|
34
|
+
"domain": "typography",
|
|
35
|
+
"level": "composed",
|
|
36
|
+
"audiences": [
|
|
37
|
+
"custom-scenes",
|
|
38
|
+
"react-developers"
|
|
39
|
+
],
|
|
40
|
+
"dependencies": [
|
|
41
|
+
"motion"
|
|
42
|
+
],
|
|
43
|
+
"useWhen": "Use it for any text-led scene where copy must fit a known box, animate as a coherent lifecycle, or be checked against a fixed scene duration.",
|
|
44
|
+
"avoidWhen": "Do not shrink copy indefinitely or choose an archetype without checking its allowed canvas and minimum duration."
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|