@vanillaskyai/video 0.8.8 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/PUBLIC-API.md +5 -8
- package/dist/{builtin-server-CUYPIDE7.js → builtin-server-HW6HGXCZ.js} +2 -2
- package/dist/{catalog-types-BIhSpOWK.d.ts → catalog-types-WTbLP6Jh.d.ts} +5 -1
- package/dist/chapter-title-YRLIQY3V.js +7 -0
- package/dist/check-runtime.js +6 -15
- package/dist/{chunk-2USVB3CY.js → chunk-3ZTEFJIR.js} +285 -178
- package/dist/chunk-4YM2M62S.js +13 -0
- package/dist/chunk-77VY4O7A.js +390 -0
- package/dist/{chunk-MMUXVA47.js → chunk-7M56IUUX.js} +9 -8
- package/dist/chunk-7NZOMTAL.js +63 -0
- package/dist/{chunk-2DOLF4QN.js → chunk-AUPC6MDK.js} +12 -14
- package/dist/{chunk-352ZZCET.js → chunk-EGVQODKU.js} +2 -2
- package/dist/chunk-EWWCQFTI.js +18 -0
- package/dist/{chunk-MXZSDGZQ.js → chunk-G7IZBBWF.js} +14 -9
- package/dist/{chunk-KT3M7KDL.js → chunk-HKUI5BTB.js} +3 -3
- package/dist/{chunk-LMG3GHLL.js → chunk-IQMYK5DX.js} +29 -3
- package/dist/chunk-LH4BPALA.js +564 -0
- package/dist/{chunk-OWBT45EN.js → chunk-MQILEEXC.js} +1 -1
- package/dist/chunk-MXEYPK7M.js +552 -0
- package/dist/{chunk-AITKH6QT.js → chunk-OR52UMSF.js} +13 -39
- package/dist/{chunk-5SLENAJW.js → chunk-PAJ44TFW.js} +1 -2
- package/dist/{chunk-L7QGGKJ2.js → chunk-PDQFQIQW.js} +1 -1
- package/dist/{chunk-WJ6FLKNC.js → chunk-R3XAOMKP.js} +14 -0
- package/dist/{chunk-2OKB27TG.js → chunk-RXTN2CW6.js} +2 -2
- package/dist/{chunk-XWWLKRNU.js → chunk-TVIU23OM.js} +38 -49
- package/dist/{chunk-224QNWRA.js → chunk-UDIODEDK.js} +1 -5
- package/dist/{chunk-3VRMKFM7.js → chunk-ZDJNFA52.js} +11 -24
- package/dist/cinema-media-YQHSIFDA.js +11 -0
- package/dist/cli.js +18 -9
- package/dist/comparison-RZMMHZR2.js +27 -0
- package/dist/compose-video-TBFTONUC.js +12 -0
- package/dist/editorial-timeline-OZX7OS6G.js +36 -0
- package/dist/{events-BQ6z264n.d.ts → events-B4YCc4vc.d.ts} +1 -1
- package/dist/focus-cards-TJ72BT7U.js +20 -0
- package/dist/index.d.ts +4 -6
- package/dist/index.js +3 -7
- package/dist/key-figure-GYENXKH3.js +20 -0
- package/dist/{kit-tQnCPMOM.d.ts → kit-DrRpdn0p.d.ts} +2 -2
- package/dist/mobile-message-UZUPIKHD.js +49 -0
- package/dist/{preload-media-LJXWKTWG.js → preload-media-CFTZIMZL.js} +1 -1
- package/dist/quote-EW5HQA6H.js +23 -0
- package/dist/react.d.ts +21 -11
- package/dist/react.js +590 -833
- package/dist/scene-video-backdrop-PCTARAAJ.js +7 -0
- package/dist/server.d.ts +7 -8
- package/dist/server.js +91 -74
- package/dist/{system-prompt-RRXIWDDD.js → system-prompt-SYRT5DWM.js} +1 -1
- package/dist/template-catalog.d.ts +257 -16
- package/dist/template-catalog.js +1 -1
- package/dist/templates.d.ts +5 -5
- package/dist/test.d.ts +3 -3
- package/dist/test.js +18 -18
- package/dist/text-stream-LD364KBI.js +9 -0
- package/dist/{types-DWFO6qca.d.ts → types-CdBmNTGD.d.ts} +1 -1
- package/dist/{types-CEh9eUFu.d.ts → types-DdZw4GRQ.d.ts} +17 -48
- package/dist/validate-4OUD2CLX.js +10 -0
- package/docs/architecture.md +1 -1
- package/docs/concepts.md +1 -1
- package/docs/custom-templates.md +13 -28
- package/docs/customization.md +11 -59
- package/docs/getting-started.md +2 -2
- package/docs/immersive-interface.md +3 -4
- package/docs/maintainers/cinematic-migration.md +34 -0
- package/docs/media-and-audio.md +14 -14
- package/docs/motion-and-effects.md +6 -14
- package/docs/persistence.md +3 -3
- package/docs/production.md +11 -9
- package/docs/provider-integration.md +9 -16
- package/docs/reference/design-system.html +1 -1
- package/docs/reference/protocol.md +14 -4
- package/docs/testing.md +1 -1
- package/package.json +6 -3
- package/registry/items/backgrounds.json +1 -1
- package/registry/items/chapterTitle.json +80 -0
- package/registry/items/cinemaMedia.json +136 -0
- package/registry/items/comparison.json +101 -0
- package/registry/items/editorialTimeline.json +105 -0
- package/registry/items/focusCards.json +90 -0
- package/registry/items/keyFigure.json +97 -0
- package/registry/items/mobileMessage.json +153 -0
- package/registry/items/quote.json +94 -0
- package/registry/items/template-context.json +1 -1
- package/registry/items/theme.json +1 -1
- package/starters/video-chat/README.md +24 -26
- package/starters/video-chat/package.json +1 -1
- package/starters/video-chat/providers/video.ts +6 -5
- package/starters/video-chat/stock.ts +35 -151
- package/styles/fonts/OFL.txt +93 -0
- package/styles/fonts/manifest.json +66 -0
- package/styles/fonts/roboto-v51-cyrillic-ext.woff2 +0 -0
- package/styles/fonts/roboto-v51-cyrillic.woff2 +0 -0
- package/styles/fonts/roboto-v51-greek-ext.woff2 +0 -0
- package/styles/fonts/roboto-v51-greek.woff2 +0 -0
- package/styles/fonts/roboto-v51-latin-ext.woff2 +0 -0
- package/styles/fonts/roboto-v51-latin.woff2 +0 -0
- package/styles/fonts/roboto-v51-math.woff2 +0 -0
- package/styles/fonts/roboto-v51-symbols.woff2 +0 -0
- package/styles/fonts/roboto-v51-vietnamese.woff2 +0 -0
- package/styles/fonts/roboto.css +91 -0
- package/styles/video-chat.css +30 -22
- package/dist/bg-confetti-HITCGLGD.js +0 -89
- package/dist/bg-emoji-VU4UZYZN.js +0 -150
- package/dist/bg-media-L34PDQXJ.js +0 -106
- package/dist/brand-message-FUJ4STFO.js +0 -256
- package/dist/chart-bar-EGPHJATL.js +0 -310
- package/dist/chart-counter-5FFWQNZO.js +0 -306
- package/dist/chart-progress-ring-JMXQLMLC.js +0 -302
- package/dist/chunk-4UNKZEAT.js +0 -3575
- package/dist/chunk-5CDAM24P.js +0 -31
- package/dist/chunk-AOWSXU2K.js +0 -18
- package/dist/chunk-CJ7F2CE6.js +0 -104
- package/dist/chunk-FRN6WKHA.js +0 -185
- package/dist/chunk-G5JPHBX7.js +0 -336
- package/dist/chunk-L5BTTCZV.js +0 -97
- package/dist/chunk-P22HHFDK.js +0 -43
- package/dist/chunk-PP3MCPN4.js +0 -1998
- package/dist/chunk-QAAO7OAY.js +0 -19
- package/dist/chunk-S7HFLYNN.js +0 -35
- package/dist/chunk-W3VDIBGR.js +0 -217
- package/dist/chunk-XGRA2MUV.js +0 -242
- package/dist/chunk-XTOXFMLM.js +0 -105
- package/dist/chunk-YJJC4N4D.js +0 -761
- package/dist/compose-video-2NFH4DZY.js +0 -13
- package/dist/cta-logo-YR7LYUR5.js +0 -212
- package/dist/cta-media-DQJBKUO2.js +0 -221
- package/dist/incoming-call-54KQ7O5A.js +0 -323
- package/dist/infographic-before-after-ROS52GOW.js +0 -460
- package/dist/infographic-feature-list-E7MGUDAN.js +0 -272
- package/dist/infographic-problem-solution-3S6NGO5N.js +0 -343
- package/dist/infographic-stat-row-SYB7AB7H.js +0 -247
- package/dist/infographic-steps-KCE6JWE2.js +0 -406
- package/dist/prompt-input-MHX4O42G.js +0 -239
- package/dist/reaction-SLEGR3BE.js +0 -122
- package/dist/scene-video-backdrop-XFPX4O3I.js +0 -10
- package/dist/showcase-code-2HLAVKBM.js +0 -357
- package/dist/showcase-phone-Q4HEO4LH.js +0 -278
- package/dist/showcase-terminal-DL45LUFG.js +0 -312
- package/dist/showcase-web-2QZO73FZ.js +0 -396
- package/dist/social-conversation-75SD2637.js +0 -695
- package/dist/social-milestone-GEFUQSBF.js +0 -296
- package/dist/social-notification-FJ5VHVLK.js +0 -247
- package/dist/social-review-stack-KXQCKFRP.js +0 -242
- package/dist/social-testimonial-JZ7CHC7S.js +0 -287
- package/dist/social-tweet-UCDXM25F.js +0 -298
- package/dist/text-stream-54LXABJW.js +0 -10
- package/dist/validate-T7GBU2YF.js +0 -8
- package/registry/items/barChart.json +0 -245
- package/registry/items/beforeAfter.json +0 -180
- package/registry/items/bigNumber.json +0 -232
- package/registry/items/brandMessage.json +0 -164
- package/registry/items/cardList.json +0 -214
- package/registry/items/chatMessenger.json +0 -151
- package/registry/items/chatWhatsapp.json +0 -151
- package/registry/items/codeEditor.json +0 -191
- package/registry/items/confetti.json +0 -107
- package/registry/items/ctaLogo.json +0 -167
- package/registry/items/ctaMedia.json +0 -200
- package/registry/items/emojiBurst.json +0 -101
- package/registry/items/incomingCall.json +0 -177
- package/registry/items/media.json +0 -187
- package/registry/items/milestone.json +0 -212
- package/registry/items/notification.json +0 -189
- package/registry/items/phoneMockup.json +0 -261
- package/registry/items/problemSolution.json +0 -193
- package/registry/items/progressRing.json +0 -206
- package/registry/items/promptInput.json +0 -155
- package/registry/items/reaction.json +0 -176
- package/registry/items/reviewStack.json +0 -231
- package/registry/items/steps.json +0 -215
- package/registry/items/terminal.json +0 -219
- package/registry/items/testimonial.json +0 -171
- package/registry/items/tripleStats.json +0 -235
- package/registry/items/tweet.json +0 -210
- package/registry/items/webMockup.json +0 -280
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "keyFigure",
|
|
4
|
+
"type": "registry:block",
|
|
5
|
+
"title": "Key figure",
|
|
6
|
+
"description": "One supplied figure with one short label on black.",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"react"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"@vanillasky/template-context",
|
|
12
|
+
"@vanillasky/theme"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "src/visual-system/scene-templates/key-figure.tsx",
|
|
17
|
+
"type": "registry:component",
|
|
18
|
+
"target": "vanillasky/scene-templates/key-figure.tsx",
|
|
19
|
+
"content": "import type {SceneTemplateProps} from './types';\nimport {editorialFont,editorialLabel,fade} from './editorial-typography';\n\n/** One supplied figure, shown directly without a count-up that invents intermediate values. */\nfunction KeyFigureScene({variables,width,height,progress,motionProgress=progress,safeZone}:SceneTemplateProps){\n const u=Math.min(width,height),side=Math.max(width*.13,safeZone.left,safeZone.right),available=width-side*2;\n const top=Math.max(height*.16,safeZone.top),bottom=height-Math.max(height*.23,safeZone.bottom),value=String(variables.value??'');\n return <div data-template=\"keyFigure\" style={{position:'absolute',inset:0,background:'#000',color:'#fff',fontFamily:editorialFont,overflow:'hidden'}}>\n <div style={{position:'absolute',left:side,top:(top+bottom)/2,width:available,transform:'translateY(-50%)',textAlign:'center',opacity:fade((motionProgress-.03)/.22)}}>\n <div style={{fontSize:Math.min(u*.22,available/Math.max(1,value.length*.6)),fontWeight:500,lineHeight:1.05,letterSpacing:'-.045em',fontVariantNumeric:'tabular-nums'}}>{value}</div>\n <div style={{...editorialLabel(u),marginTop:u*.045,textWrap:'balance',overflowWrap:'anywhere'}}>{String(variables.label??'')}</div>\n </div>\n </div>;\n}\n\nexport const KeyFigureSceneTemplate = KeyFigureScene;\n"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"path": "src/visual-system/scene-templates/types.ts",
|
|
23
|
+
"type": "registry:component",
|
|
24
|
+
"target": "vanillasky/scene-templates/types.ts",
|
|
25
|
+
"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"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"path": "src/visual-system/scene-templates/editorial-typography.ts",
|
|
29
|
+
"type": "registry:component",
|
|
30
|
+
"target": "vanillasky/scene-templates/editorial-typography.ts",
|
|
31
|
+
"content": "/** Shared typography for quiet editorial scenes. */\nexport const editorialFont = '-apple-system, BlinkMacSystemFont, \"Helvetica Neue\", Roboto, Arial, sans-serif';\nexport const fade = (value: number) => { const t = Math.max(0, Math.min(1, value)); return t * t * (3 - 2 * t); };\n\nexport const editorialLabel = (unit: number) => ({fontSize:unit*.042,fontWeight:400,color:'#b7b7bc',lineHeight:1.25} as const);\n"
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"meta": {
|
|
35
|
+
"vanillasky": {
|
|
36
|
+
"layer": "template",
|
|
37
|
+
"tier": "free",
|
|
38
|
+
"label": "Key figure",
|
|
39
|
+
"description": "One supplied figure with one short label on black.",
|
|
40
|
+
"usesGlobalTextEffect": false,
|
|
41
|
+
"usesGlobalTransition": false,
|
|
42
|
+
"usesGlobalBackgroundEffect": false,
|
|
43
|
+
"textCanvas": "tight",
|
|
44
|
+
"family": "Explainers",
|
|
45
|
+
"jobs": [
|
|
46
|
+
"proof"
|
|
47
|
+
],
|
|
48
|
+
"register": "motion-led",
|
|
49
|
+
"useWhen": "One verified quantity or supplied target anchors the explanation. Value, unit and context must match supplied evidence.",
|
|
50
|
+
"avoidWhen": "No reliable figure is supplied or multiple values require a chart.",
|
|
51
|
+
"minDuration": 4,
|
|
52
|
+
"preferredDuration": 5,
|
|
53
|
+
"timing": {
|
|
54
|
+
"contentFields": [
|
|
55
|
+
"value",
|
|
56
|
+
"label"
|
|
57
|
+
],
|
|
58
|
+
"contentUnit": "words",
|
|
59
|
+
"revealSeconds": 0.8,
|
|
60
|
+
"holdSeconds": 2.5,
|
|
61
|
+
"exitSeconds": 0
|
|
62
|
+
},
|
|
63
|
+
"schema": {
|
|
64
|
+
"type": "object",
|
|
65
|
+
"properties": {
|
|
66
|
+
"value": {
|
|
67
|
+
"type": "string",
|
|
68
|
+
"minLength": 1,
|
|
69
|
+
"maxLength": 14,
|
|
70
|
+
"format": "grounded-stat",
|
|
71
|
+
"examples": [
|
|
72
|
+
"42%"
|
|
73
|
+
],
|
|
74
|
+
"description": "One supplied supported quantity, preserving its unit."
|
|
75
|
+
},
|
|
76
|
+
"label": {
|
|
77
|
+
"type": "string",
|
|
78
|
+
"minLength": 1,
|
|
79
|
+
"maxLength": 50,
|
|
80
|
+
"examples": [
|
|
81
|
+
"Illustrative example"
|
|
82
|
+
],
|
|
83
|
+
"description": "Exactly one short label identifying the quantity and its context."
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"required": [
|
|
87
|
+
"value",
|
|
88
|
+
"label"
|
|
89
|
+
],
|
|
90
|
+
"additionalProperties": false,
|
|
91
|
+
"x-vanillasky": {
|
|
92
|
+
"requiresStat": true
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "mobileMessage",
|
|
4
|
+
"type": "registry:block",
|
|
5
|
+
"title": "Reach out",
|
|
6
|
+
"description": "A frosted glass message over resolved media, without sender or timestamp.",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"react"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"@vanillasky/backgrounds",
|
|
12
|
+
"@vanillasky/motion",
|
|
13
|
+
"@vanillasky/template-context",
|
|
14
|
+
"@vanillasky/theme"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "src/visual-system/scene-templates/mobile-message.tsx",
|
|
19
|
+
"type": "registry:component",
|
|
20
|
+
"target": "vanillasky/scene-templates/mobile-message.tsx",
|
|
21
|
+
"content": "import type { SceneTemplateProps } from './types';\nimport { spring } from '../motion';\n\nimport {MediaScene} from './cinema-media';\nimport {editorialFont} from './editorial-typography';\n\nconst clamp = (value: number) => Math.max(0, Math.min(1, value));\n\n/** Refines the original floating NotificationCard; the message arrives as one native alert. */\nfunction NotificationScene(props: SceneTemplateProps) {\n const {variables,progress,motionProgress=progress,sceneDuration=6,width,height,safeZone}=props;\n const message = String(variables.message ?? '');\n const app = String(variables.app ?? 'Messages');\n const unit = Math.min(width, height);\n const cardWidth = Math.min(width * .88, unit * 1.12, width - safeZone.left - safeZone.right - unit * .06);\n const inset = unit * .038;\n const elapsed = clamp(motionProgress) * Math.max(2, Math.min(sceneDuration, 8));\n const entrance = clamp((elapsed - .18) / .82);\n const arrival = entrance >= 1 ? 1 : spring(entrance, {damping:22,stiffness:220});\n const opacity = clamp((elapsed - .18) / .08);\n const contentWidth = cardWidth - inset * 2;\n const messageSize = unit * (message.length > 100 ? .038 : .043);\n const longestWord = Math.max(1, ...message.split(/\\s+/).map(word => word.length));\n const fittedMessageSize = Math.min(messageSize, contentWidth / (longestWord * .57));\n const bannerTop = Math.max(safeZone.top, height * .08) + unit * .025;\n const travel = bannerTop + unit * .65;\n const iconSize = unit * .049;\n return <div data-template=\"notification\" style={{position: 'absolute', inset: 0, overflow: 'hidden', background:'var(--vanillasky-template-surface, #000)', fontFamily:editorialFont}}>\n <MediaScene {...props}/>\n <div aria-hidden=\"true\" style={{position:\"absolute\",inset:0,background:\"rgba(0,0,0,.16)\"}}/>\n <div data-notification-card=\"true\" style={{position: 'absolute', left: (width - cardWidth) / 2, top: bannerTop, width: cardWidth, boxSizing: 'border-box', transform: `translateY(${-(1-arrival)*travel}px) scale(${.985+arrival*.015})`, opacity, transformOrigin: '50% 0%', padding: inset, borderRadius: unit * .042, background:'rgba(245,247,250,.64)', backdropFilter:`blur(${unit*.034}px) saturate(135%)`, WebkitBackdropFilter:`blur(${unit*.034}px) saturate(135%)`, border: `${unit * .001}px solid rgba(255,255,255,.72)`, boxShadow:`0 ${unit*.018}px ${unit*.065}px rgba(0,0,0,.2), inset 0 ${unit*.001}px 0 rgba(255,255,255,.8)`, color: '#202025'}}>\n <div data-notification-header=\"true\" style={{display: 'flex', alignItems: 'center', gap: unit * .014, marginBottom: unit * .024}}>\n <div style={{width: iconSize, height: iconSize, borderRadius: iconSize * .25, display: 'grid', placeItems: 'center', background: 'linear-gradient(#5bd978, #25b749)', flexShrink: 0}}>\n <svg viewBox=\"0 0 40 40\" width={iconSize * .78} height={iconSize * .78} aria-hidden=\"true\"><path d=\"M34 18c0 7-6 12-14 12-2 0-4 0-6-1l-7 3 2-7c-2-2-3-4-3-7C6 11 12 6 20 6s14 5 14 12Z\" fill=\"white\" /></svg>\n </div>\n <div style={{fontSize: unit * .029, fontWeight: 500, color: '#5b565f', minWidth: 0, overflowWrap: 'anywhere'}}>{app}</div>\n </div>\n <div style={{fontSize: fittedMessageSize, lineHeight: 1.26, letterSpacing: '-.016em', overflowWrap: 'anywhere'}}>{message}</div>\n </div>\n </div>;\n}\n\nexport const NotificationSceneTemplate = NotificationScene;\n"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"path": "src/visual-system/scene-templates/types.ts",
|
|
25
|
+
"type": "registry:component",
|
|
26
|
+
"target": "vanillasky/scene-templates/types.ts",
|
|
27
|
+
"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"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"path": "src/visual-system/scene-templates/cinema-media.tsx",
|
|
31
|
+
"type": "registry:component",
|
|
32
|
+
"target": "vanillasky/scene-templates/cinema-media.tsx",
|
|
33
|
+
"content": "import type { SceneTemplateProps } from './types';\nimport { SceneBackground, getMediaBackgroundProps } from './scene-background';\n\n/** The shared SDK media plane owns playback, decode readiness, and poster handoff. */\nexport function MediaScene({variables,style,width,height,progress,sceneDuration,isPlaying}:SceneTemplateProps) {\n return <div data-template=\"cinemaMedia\" style={{width,height,position:'relative',overflow:'hidden'}}>\n <SceneBackground style={style} width={width} height={height} progress={progress} sceneDuration={sceneDuration} {...getMediaBackgroundProps(variables)} mediaTreatment=\"none\" backgroundEffect=\"none\" isPlaying={isPlaying}/>\n </div>;\n}\nexport const MediaSceneTemplate = MediaScene;\n"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"path": "src/visual-system/scene-templates/scene-background.tsx",
|
|
37
|
+
"type": "registry:component",
|
|
38
|
+
"target": "vanillasky/scene-templates/scene-background.tsx",
|
|
39
|
+
"content": "/**\n * Shared photo/video backdrop for built-in and customer-owned templates.\n * Media is host-resolved; this component never searches or generates assets.\n * The base is fixed black. Photos and videos cover it when available, and\n * optional scrims appear only once the media can paint. The cinematic built-ins\n * request no scrim; custom templates can choose a treatment and text anchor.\n * The retained internal \"gradient\" media sentinel selects the black base and\n * ignores mediaUrl. It is not a built-in authoring mode or brand-color control.\n * Video playback can be owned by the player's persistent external backdrop.\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 // black-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 * decode window and the black base shows 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 /** Retained seed input for customer-owned backdrop compositions. */\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 // Apply picture and scrim together. A loading or failed asset keeps the\n // fixed black base instead of painting contrast treatment over empty media.\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"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"path": "src/visual-system/scene-templates/media-source.ts",
|
|
43
|
+
"type": "registry:component",
|
|
44
|
+
"target": "vanillasky/scene-templates/media-source.ts",
|
|
45
|
+
"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"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"path": "src/visual-system/scene-templates/editorial-typography.ts",
|
|
49
|
+
"type": "registry:component",
|
|
50
|
+
"target": "vanillasky/scene-templates/editorial-typography.ts",
|
|
51
|
+
"content": "/** Shared typography for quiet editorial scenes. */\nexport const editorialFont = '-apple-system, BlinkMacSystemFont, \"Helvetica Neue\", Roboto, Arial, sans-serif';\nexport const fade = (value: number) => { const t = Math.max(0, Math.min(1, value)); return t * t * (3 - 2 * t); };\n\nexport const editorialLabel = (unit: number) => ({fontSize:unit*.042,fontWeight:400,color:'#b7b7bc',lineHeight:1.25} as const);\n"
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
"meta": {
|
|
55
|
+
"vanillasky": {
|
|
56
|
+
"layer": "template",
|
|
57
|
+
"tier": "free",
|
|
58
|
+
"label": "Reach out",
|
|
59
|
+
"description": "A frosted glass message over resolved media, without sender or timestamp.",
|
|
60
|
+
"usesGlobalTextEffect": false,
|
|
61
|
+
"usesGlobalTransition": false,
|
|
62
|
+
"usesGlobalBackgroundEffect": false,
|
|
63
|
+
"textCanvas": "tight",
|
|
64
|
+
"family": "Social & messaging",
|
|
65
|
+
"jobs": [
|
|
66
|
+
"setup"
|
|
67
|
+
],
|
|
68
|
+
"register": "card-led",
|
|
69
|
+
"useWhen": "A person or organization reaches out with a short message, alert or invitation. Message must be supplied or explicitly illustrative.",
|
|
70
|
+
"avoidWhen": "The scene is a generic fact, a long exchange, or a message that would fabricate evidence.",
|
|
71
|
+
"minDuration": 4,
|
|
72
|
+
"preferredDuration": 6,
|
|
73
|
+
"timing": {
|
|
74
|
+
"contentFields": [
|
|
75
|
+
"message",
|
|
76
|
+
"app"
|
|
77
|
+
],
|
|
78
|
+
"contentUnit": "words",
|
|
79
|
+
"revealSeconds": 1,
|
|
80
|
+
"holdSeconds": 2.2,
|
|
81
|
+
"exitSeconds": 0
|
|
82
|
+
},
|
|
83
|
+
"schema": {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"properties": {
|
|
86
|
+
"mediaKeyword": {
|
|
87
|
+
"type": "string",
|
|
88
|
+
"format": "stock-media-keyword",
|
|
89
|
+
"minLength": 1,
|
|
90
|
+
"maxLength": 80,
|
|
91
|
+
"description": "2–8 word literal subject/action search intent, maximum 80 characters. Host resolves URLs.",
|
|
92
|
+
"examples": [
|
|
93
|
+
"Ocean waves breaking on a rocky shore"
|
|
94
|
+
]
|
|
95
|
+
},
|
|
96
|
+
"mediaUrl": {
|
|
97
|
+
"type": "string",
|
|
98
|
+
"format": "uri",
|
|
99
|
+
"description": "Host-only approved photo or video URL.",
|
|
100
|
+
"default": ""
|
|
101
|
+
},
|
|
102
|
+
"mediaType": {
|
|
103
|
+
"type": "string",
|
|
104
|
+
"enum": [
|
|
105
|
+
"photo",
|
|
106
|
+
"video"
|
|
107
|
+
],
|
|
108
|
+
"default": "video",
|
|
109
|
+
"description": "Host-resolved asset kind."
|
|
110
|
+
},
|
|
111
|
+
"mediaPoster": {
|
|
112
|
+
"type": "string",
|
|
113
|
+
"format": "uri",
|
|
114
|
+
"description": "Host-only approved poster URL for video decoding.",
|
|
115
|
+
"default": ""
|
|
116
|
+
},
|
|
117
|
+
"message": {
|
|
118
|
+
"type": "string",
|
|
119
|
+
"maxLength": 120,
|
|
120
|
+
"default": "Can we talk?",
|
|
121
|
+
"description": "Exact supplied or explicitly illustrative message, preferably 6–16 words."
|
|
122
|
+
},
|
|
123
|
+
"app": {
|
|
124
|
+
"type": "string",
|
|
125
|
+
"maxLength": 24,
|
|
126
|
+
"default": "Messages",
|
|
127
|
+
"description": "Optional app label; Messages is the visual default."
|
|
128
|
+
},
|
|
129
|
+
"shotDirection": {
|
|
130
|
+
"type": "string",
|
|
131
|
+
"maxLength": 220,
|
|
132
|
+
"description": "Optional action, framing and continuity for generation. Preserve the subject; do not request rendered text."
|
|
133
|
+
},
|
|
134
|
+
"mediaSource": {
|
|
135
|
+
"type": "string",
|
|
136
|
+
"enum": [
|
|
137
|
+
"generate",
|
|
138
|
+
"stock"
|
|
139
|
+
],
|
|
140
|
+
"description": "Generate distinctive illustrative shots; use approved stock for familiar observable subjects."
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
"required": [
|
|
144
|
+
"message"
|
|
145
|
+
],
|
|
146
|
+
"additionalProperties": false,
|
|
147
|
+
"x-vanillasky": {
|
|
148
|
+
"allowsStockMedia": true
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "quote",
|
|
4
|
+
"type": "registry:block",
|
|
5
|
+
"title": "Quote",
|
|
6
|
+
"description": "A short exact quotation with a legible attribution on black.",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"react"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"@vanillasky/template-context",
|
|
12
|
+
"@vanillasky/theme"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "src/visual-system/scene-templates/quote.tsx",
|
|
17
|
+
"type": "registry:component",
|
|
18
|
+
"target": "vanillasky/scene-templates/quote.tsx",
|
|
19
|
+
"content": "import type {SceneTemplateProps} from './types';\nimport {editorialFont,fade} from './editorial-typography';\n\n/** Exact words with a readable attribution; no decorative oversized quotation mark. */\nfunction QuoteScene({variables,width,height,progress,motionProgress=progress,safeZone}:SceneTemplateProps){\n const u=Math.min(width,height),top=Math.max(height*.16,safeZone.top),bottom=height-Math.max(height*.23,safeZone.bottom);\n const side=Math.max(width*.13,safeZone.left,safeZone.right);\n return <div data-template=\"quote\" style={{position:'absolute',inset:0,background:'#000',color:'#fff',fontFamily:editorialFont,overflow:'hidden'}}>\n <div style={{position:'absolute',left:side,top:(top+bottom)/2,width:width-side*2,transform:'translateY(-50%)',textAlign:'center',opacity:fade((motionProgress-.03)/.24)}}>\n <div style={{fontSize:u*.067,fontWeight:500,lineHeight:1.25,letterSpacing:'-.025em',textWrap:'balance',overflowWrap:'anywhere'}}>“{String(variables.quote??'')}”</div>\n <div style={{fontSize:u*.041,fontWeight:400,color:'#b7b7bc',lineHeight:1.3,marginTop:u*.065,textWrap:'balance',overflowWrap:'anywhere'}}>{String(variables.attribution??'')}</div>\n </div>\n </div>;\n}\n\nexport const QuoteSceneTemplate = QuoteScene;\n"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"path": "src/visual-system/scene-templates/types.ts",
|
|
23
|
+
"type": "registry:component",
|
|
24
|
+
"target": "vanillasky/scene-templates/types.ts",
|
|
25
|
+
"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"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"path": "src/visual-system/scene-templates/editorial-typography.ts",
|
|
29
|
+
"type": "registry:component",
|
|
30
|
+
"target": "vanillasky/scene-templates/editorial-typography.ts",
|
|
31
|
+
"content": "/** Shared typography for quiet editorial scenes. */\nexport const editorialFont = '-apple-system, BlinkMacSystemFont, \"Helvetica Neue\", Roboto, Arial, sans-serif';\nexport const fade = (value: number) => { const t = Math.max(0, Math.min(1, value)); return t * t * (3 - 2 * t); };\n\nexport const editorialLabel = (unit: number) => ({fontSize:unit*.042,fontWeight:400,color:'#b7b7bc',lineHeight:1.25} as const);\n"
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"meta": {
|
|
35
|
+
"vanillasky": {
|
|
36
|
+
"layer": "template",
|
|
37
|
+
"tier": "free",
|
|
38
|
+
"label": "Quote",
|
|
39
|
+
"description": "A short exact quotation with a legible attribution on black.",
|
|
40
|
+
"usesGlobalTextEffect": false,
|
|
41
|
+
"usesGlobalTransition": false,
|
|
42
|
+
"usesGlobalBackgroundEffect": false,
|
|
43
|
+
"textCanvas": "tight",
|
|
44
|
+
"family": "Explainers",
|
|
45
|
+
"jobs": [
|
|
46
|
+
"proof"
|
|
47
|
+
],
|
|
48
|
+
"register": "motion-led",
|
|
49
|
+
"useWhen": "A supplied quotation deserves its own moment. Use exact source words and attribution only.",
|
|
50
|
+
"avoidWhen": "No exact quotation or reliable attribution is supplied; never invent testimony.",
|
|
51
|
+
"minDuration": 5,
|
|
52
|
+
"preferredDuration": 7,
|
|
53
|
+
"timing": {
|
|
54
|
+
"contentFields": [
|
|
55
|
+
"quote",
|
|
56
|
+
"attribution"
|
|
57
|
+
],
|
|
58
|
+
"contentUnit": "words",
|
|
59
|
+
"revealSeconds": 1.2,
|
|
60
|
+
"holdSeconds": 3,
|
|
61
|
+
"exitSeconds": 0
|
|
62
|
+
},
|
|
63
|
+
"schema": {
|
|
64
|
+
"type": "object",
|
|
65
|
+
"properties": {
|
|
66
|
+
"quote": {
|
|
67
|
+
"type": "string",
|
|
68
|
+
"minLength": 1,
|
|
69
|
+
"maxLength": 140,
|
|
70
|
+
"format": "grounded-quote",
|
|
71
|
+
"examples": [
|
|
72
|
+
"Look closely. There is always more to see."
|
|
73
|
+
],
|
|
74
|
+
"description": "Exact supplied quotation; do not paraphrase or invent."
|
|
75
|
+
},
|
|
76
|
+
"attribution": {
|
|
77
|
+
"type": "string",
|
|
78
|
+
"minLength": 1,
|
|
79
|
+
"maxLength": 60,
|
|
80
|
+
"examples": [
|
|
81
|
+
"Illustrative example"
|
|
82
|
+
],
|
|
83
|
+
"description": "Grounded attribution belonging to these exact quoted words."
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"required": [
|
|
87
|
+
"quote",
|
|
88
|
+
"attribution"
|
|
89
|
+
],
|
|
90
|
+
"additionalProperties": false
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"path": "src/visual-system/template-context.ts",
|
|
10
10
|
"type": "registry:lib",
|
|
11
11
|
"target": "vanillasky/template-context.ts",
|
|
12
|
-
"content": "/** Shared render context for built-in and project-owned scene templates. */\nexport interface TemplateStyle {\n
|
|
12
|
+
"content": "/** Shared render context for built-in and project-owned scene templates. */\nexport interface TemplateStyle {\n preset?: string;\n density?: string;\n motion?: string;\n defaultTextArchetype?: string;\n defaultTransition?: string;\n defaultBackgroundEffect?: string;\n}\n\nexport interface SafeZone {\n top: number;\n bottom: number;\n left: number;\n right: number;\n}\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"meta": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"path": "src/visual-system/scene-templates/tokens.ts",
|
|
25
25
|
"type": "registry:lib",
|
|
26
26
|
"target": "vanillasky/scene-templates/tokens.ts",
|
|
27
|
-
"content": "/**\n * Template-token adapter for an already-resolved semantic video brand.\n *\n * Before this module the default brand was defined in 19+ places with three\n * different answers (17 templates hardcoded \"#00e5a0\", milestone used\n * \"#3b82f6\", tweet hashes the author name), the font-stack idiom was\n * copy-pasted in 22 files, and shiftHue(accent, 50) was re-derived in 14.\n * Every fallback now lives here, exactly once.\n *\n * `deriveBrandContext` (src/visual-system/primitives/brand-context.ts) delegates to\n * this resolver, so templates and primitives can never drift.\n *\n * Documented divergences that stay OUTSIDE the canonical fallbacks:\n * - social-tweet derives a per-author hue when no brand accent is set —\n * an intentional feature, routed through `accentFallback`.\n * - Platform-look templates (incoming-call, social-conversation,\n * social-notification, brand-message) keep their OS/system font stacks;\n * they imitate iOS/WhatsApp/X chrome, not the brand.\n */\n\nimport type { TemplateStyle } from \"../template-context\";\n\n// ─── Canonical defaults (the only place these values are defined) ──\n\nexport const TOKEN_DEFAULTS = {\n /** Primary brand colour — CTA, highlights. */\n primary: \"#00E5A0\",\n /** Deepest background surface. */\n surface: \"#0A0A14\",\n /** Elevated card / panel surface. */\n surfaceElevated: \"#14152A\",\n /** Primary text color. */\n foreground: \"#FFFFFF\",\n /** Muted text — labels, footers, supporting copy. */\n muted: \"#A7A6B0\",\n /** Primary sans font family (first name of the stack). */\n font: \"Inter\",\n /** Script accent font family for handwritten callouts. */\n scriptFont: \"Caveat\",\n} as const;\n\n/**\n * The canonical template font stack: first family of the resolved brand font, backed by\n * OS-native sans fallbacks that render identically in preview and the\n * SVG-as-image export path.\n */\nexport function fontStack(styleFont: string | undefined): string {\n return `${(styleFont || \"\").split(\",\")[0].trim() || TOKEN_DEFAULTS.font}, -apple-system, BlinkMacSystemFont, \"Helvetica Neue\", Helvetica, Arial, sans-serif`;\n}\n\n// ─── Resolved tokens ───────────────────────────────────────────────\n\n// ─── Style presets ─────────────────────────────────────────────────\n\n/**\n * Background families a preset can pick. Each resolves to a pure CSS\n * background string in `gradientBackground` — no CSS `filter`, which the\n * SVG export path cannot rasterize.\n */\nexport type BackgroundFamily = \"mesh\" | \"wash\" | \"spotlight\";\n\n/** Title placement a preset defaults to (SceneFrameVariant, minus no-title). */\nexport type PresetTitlePlacement = \"title-top\" | \"title-center\";\n\nexport interface TypeTreatment {\n /** Added to the computed fontWeight (clamped 100–900). */\n weightDelta: number;\n /** Added to the size role's letterSpacing, in em. */\n trackingDeltaEm: number;\n /** Multiplies the computed fontSize. */\n sizeScale: number;\n /** Applied as CSS text-transform when set. */\n transform?: \"uppercase\";\n /**\n * Multiplies every text-archetype entrance/exit phase duration. Set by\n * `resolveTokens` from `style.motion`; absent on the raw preset literals,\n * where it reads as 1.\n *\n * It rides on the type treatment because that object is the one channel\n * that already flows from `style` into every `<TemplateText>` — 17 call\n * sites pass `resolveTokens(style).preset.type` and nothing else\n * style-derived. Pacing of typographic motion is part of how the type is\n * treated, so this isn't a smuggled payload.\n */\n phaseScale?: number;\n}\n\nexport interface StylePreset {\n id: string;\n /** Agent-facing use-when — surfaced in the registry index. */\n useWhen: string;\n background: BackgroundFamily;\n titlePlacement: PresetTitlePlacement;\n type: TypeTreatment;\n}\n\n/**\n * The named looks. `bold` is the default and is a deliberate no-op: a config\n * with no `preset` resolves to it and renders byte-identically to the\n * pre-preset output, so adding presets can't restyle anyone's existing video.\n *\n * Deliberately small. Every preset multiplies the QA surface by every\n * template at both orientations — grow this only when a brief can't be\n * expressed by the ones here.\n */\nexport const STYLE_PRESETS: Record<string, StylePreset> = {\n bold: {\n id: \"bold\",\n useWhen:\n \"The default. Drifting two-color brand mesh, heavy tight headlines at the top. Launches, hype, product moments — the loudest of the three.\",\n background: \"mesh\",\n titlePlacement: \"title-top\",\n type: { weightDelta: 0, trackingDeltaEm: 0, sizeScale: 1 },\n },\n editorial: {\n id: \"editorial\",\n useWhen:\n \"Calm vertical wash, lighter and wider-tracked headlines, centered. Reviews, thoughtful updates, premium or B2B brands — when the copy should feel considered rather than shouted.\",\n background: \"wash\",\n titlePlacement: \"title-center\",\n type: { weightDelta: -200, trackingDeltaEm: 0.01, sizeScale: 1.08 },\n },\n stark: {\n id: \"stark\",\n useWhen:\n \"Single hard spotlight on near-black, uppercase and tightly tracked. Dev tools, technical claims, high-contrast statements — maximum weight on very few words.\",\n background: \"spotlight\",\n titlePlacement: \"title-top\",\n type: { weightDelta: 100, trackingDeltaEm: -0.01, sizeScale: 1, transform: \"uppercase\" },\n },\n};\n\nexport const DEFAULT_PRESET_ID = \"bold\";\nexport const PRESET_IDS = Object.keys(STYLE_PRESETS);\n\n/** Unknown/unset ids fall back to the default rather than throwing — a bad\n * preset should never be the reason a render fails. */\nexport function resolvePreset(id: string | undefined): StylePreset {\n return (id && STYLE_PRESETS[id]) || STYLE_PRESETS[DEFAULT_PRESET_ID];\n}\n\n// ─── Density & motion ──────────────────────────────────────────────\n//\n// Two dimensions orthogonal to the named preset. `preset` answers \"which\n// look\"; these answer \"how loud\". Splitting them is what lets \"make the\n// whole video more understated\" be one instruction instead of hand-tuning\n// every scene: they resolve into multipliers on levers that already reach\n// all 28 templates, so no template file knows they exist.\n//\n// Both default to `normal`, whose multipliers are all 1 — a config that sets\n// neither renders byte-identically to the pre-density output. Same invariant\n// the presets hold.\n\nexport type StyleDensity = \"airy\" | \"normal\" | \"packed\";\nexport type StyleMotion = \"calm\" | \"normal\" | \"punchy\";\n\nexport interface DensityScale {\n id: StyleDensity;\n /** Agent-facing use-when — surfaced in the registry index. */\n useWhen: string;\n /** Multiplies the headline font size (via the preset's `type.sizeScale`). */\n typeScale: number;\n /** Multiplies the frame's safe-zone insets — bigger insets, more air. */\n safeZoneScale: number;\n}\n\nexport interface MotionScale {\n id: StyleMotion;\n /** Agent-facing use-when — surfaced in the registry index. */\n useWhen: string;\n /** Multiplies every text-archetype entrance/exit phase duration. */\n phaseScale: number;\n}\n\nexport const DENSITY_SCALES: Record<StyleDensity, DensityScale> = {\n airy: {\n id: \"airy\",\n useWhen:\n \"Smaller headlines held further off the frame edges. Premium, considered, editorial — when the copy should have room to breathe.\",\n typeScale: 0.92,\n safeZoneScale: 1.3,\n },\n normal: {\n id: \"normal\",\n useWhen: \"The default. No change to type size or frame padding.\",\n typeScale: 1,\n safeZoneScale: 1,\n },\n packed: {\n id: \"packed\",\n useWhen:\n \"Bigger headlines pushed closer to the edges. Dense, urgent, information-heavy — when the frame should feel full.\",\n typeScale: 1.08,\n safeZoneScale: 0.8,\n },\n};\n\nexport const MOTION_SCALES: Record<StyleMotion, MotionScale> = {\n calm: {\n id: \"calm\",\n useWhen:\n \"Slower entrances and exits — text eases in rather than arriving. Founder stories, sober data, anything reflective.\",\n phaseScale: 1.4,\n },\n normal: {\n id: \"normal\",\n useWhen: \"The default. Archetype timings as authored.\",\n phaseScale: 1,\n },\n punchy: {\n id: \"punchy\",\n useWhen:\n \"Snappier entrances and exits — text lands fast and clears fast. Hype, launches, hot takes.\",\n phaseScale: 0.7,\n },\n};\n\nexport const DEFAULT_DENSITY_ID: StyleDensity = \"normal\";\nexport const DEFAULT_MOTION_ID: StyleMotion = \"normal\";\nexport const DENSITY_IDS = Object.keys(DENSITY_SCALES) as StyleDensity[];\nexport const MOTION_IDS = Object.keys(MOTION_SCALES) as StyleMotion[];\n\n/** Unknown/unset ids fall back to `normal`, same as `resolvePreset`. */\nexport function resolveDensity(id: string | undefined): DensityScale {\n return (id && DENSITY_SCALES[id as StyleDensity]) || DENSITY_SCALES[DEFAULT_DENSITY_ID];\n}\n\n/** Unknown/unset ids fall back to `normal`, same as `resolvePreset`. */\nexport function resolveMotion(id: string | undefined): MotionScale {\n return (id && MOTION_SCALES[id as StyleMotion]) || MOTION_SCALES[DEFAULT_MOTION_ID];\n}\n\nexport interface ResolvedTokens {\n /** Primary brand colour. */\n primary: string;\n /** Secondary brand colour. */\n secondary: string;\n /** Visual background, deliberately separate from semantic foreground colours. */\n background: TemplateStyle[\"brand\"][\"background\"];\n /** Deepest background surface. */\n surface: string;\n /** Elevated card / panel surface. */\n surfaceElevated: string;\n /** Primary foreground colour. */\n foreground: string;\n /** Muted text color. */\n muted: string;\n /** Full font fallback stack (see fontStack). */\n font: string;\n /** Script accent font family. */\n scriptFont: string;\n logoUrl?: string;\n name?: string;\n /**\n * Resolved style preset — frame-level look. Always set.\n *\n * `preset.type` is the composed treatment, not the raw preset literal:\n * `sizeScale` already carries the density multiplier and `phaseScale`\n * carries the motion one. Templates pass this straight to `<TemplateText>`,\n * which is how both dials reach every template without a template edit.\n */\n preset: StylePreset;\n /** Resolved density dial. Always set; `normal` when unset. */\n density: DensityScale;\n /** Resolved motion dial. Always set; `normal` when unset. */\n motion: MotionScale;\n}\n\nexport function resolveTokens(\n style: TemplateStyle,\n): ResolvedTokens {\n const brand = style.brand;\n\n // Compose the two dials into the preset's type treatment here, once. Every\n // template already passes `preset.type` to <TemplateText>, so folding them\n // in at the resolver is what makes them bite everywhere with no template\n // edits. At `normal`/`normal` both multipliers are 1 and the object is\n // value-identical to the preset literal.\n const preset = resolvePreset(style.preset);\n const density = resolveDensity(style.density);\n const motion = resolveMotion(style.motion);\n const composedPreset: StylePreset = {\n ...preset,\n type: {\n ...preset.type,\n sizeScale: preset.type.sizeScale * density.typeScale,\n phaseScale: motion.phaseScale,\n },\n };\n\n return {\n primary: brand.colors.primary,\n secondary: brand.colors.secondary,\n background: brand.background,\n surface: brand.colors.surface,\n surfaceElevated: brand.colors.surfaceElevated,\n foreground: brand.colors.foreground,\n muted: brand.colors.muted,\n font: fontStack(brand.font),\n scriptFont: brand.scriptFont,\n logoUrl: brand.logoUrl,\n name: brand.name,\n preset: composedPreset,\n density,\n motion,\n };\n}\n\n// ─── Color math the resolver depends on ────────────────────────────\n// (Lives beside the resolver because theme owns both token resolution and\n// color derivation.)\n\n/**\n * Shift a hex color's hue by a number of degrees.\n * Used by internal template treatments that need a related hue.\n */\nexport function shiftHue(hex: string, degrees: number): string {\n const r = parseInt(hex.slice(1, 3), 16) / 255;\n const g = parseInt(hex.slice(3, 5), 16) / 255;\n const b = parseInt(hex.slice(5, 7), 16) / 255;\n\n const max = Math.max(r, g, b);\n const min = Math.min(r, g, b);\n const d = max - min;\n const l = (max + min) / 2;\n let h = 0;\n let s = 0;\n\n if (d > 0) {\n s = l > 0.5 ? d / (2 - max - min) : d / (max + min);\n if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;\n else if (max === g) h = ((b - r) / d + 2) / 6;\n else h = ((r - g) / d + 4) / 6;\n }\n\n h = (h + degrees / 360 + 1) % 1;\n\n const hue2rgb = (p: number, q: number, t: number) => {\n if (t < 0) t += 1;\n if (t > 1) t -= 1;\n if (t < 1 / 6) return p + (q - p) * 6 * t;\n if (t < 1 / 2) return q;\n if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;\n return p;\n };\n\n let r2: number, g2: number, b2: number;\n if (s === 0) {\n r2 = g2 = b2 = l;\n } else {\n const q = l < 0.5 ? l * (1 + s) : l + s - l * s;\n const p = 2 * l - q;\n r2 = hue2rgb(p, q, h + 1 / 3);\n g2 = hue2rgb(p, q, h);\n b2 = hue2rgb(p, q, h - 1 / 3);\n }\n\n const toHex = (v: number) =>\n Math.round(v * 255)\n .toString(16)\n .padStart(2, \"0\");\n return `#${toHex(r2)}${toHex(g2)}${toHex(b2)}`;\n}\n\n/** Lighten a hex color by a 0-1 factor. */\n/** Scale a hex color toward black by `factor` (0 = unchanged, 1 = black). */\nexport function darken(hex: string, factor: number): string {\n if (!hex.startsWith(\"#\") || (hex.length !== 4 && hex.length !== 7)) {\n return hex;\n }\n const full = hex.length === 4\n ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`\n : hex;\n const ch = (i: number) =>\n Math.max(0, Math.round(parseInt(full.slice(i, i + 2), 16) * (1 - factor)))\n .toString(16)\n .padStart(2, \"0\");\n return `#${ch(1)}${ch(3)}${ch(5)}`;\n}\n\nexport function lighten(hex: string, factor: number): string {\n if (!hex.startsWith(\"#\") || (hex.length !== 4 && hex.length !== 7)) {\n return hex;\n }\n const full = hex.length === 4\n ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`\n : hex;\n const r = parseInt(full.slice(1, 3), 16);\n const g = parseInt(full.slice(3, 5), 16);\n const b = parseInt(full.slice(5, 7), 16);\n const lr = Math.min(255, Math.round(r + (255 - r) * factor));\n const lg = Math.min(255, Math.round(g + (255 - g) * factor));\n const lb = Math.min(255, Math.round(b + (255 - b) * factor));\n return `#${lr.toString(16).padStart(2, \"0\")}${lg.toString(16).padStart(2, \"0\")}${lb.toString(16).padStart(2, \"0\")}`;\n}\n\n/**\n * Per-glyph halo for type sitting directly on photo or video.\n *\n * A frame-wide scrim can only trade picture for contrast, and it loses that\n * trade against blown-out highlights: holding white type at 4.5:1 over a\n * near-white region needs ~0.8 alpha of black across the whole plate. A\n * two-layer shadow buys the same local separation for free — a tight 8px pass\n * for edge definition against fine texture, a wide 16px pass for the soft\n * falloff that separates the word from whatever sits behind it. Export-safe:\n * `text-shadow` survives SVG capture, `filter`/`backdrop-filter` do not.\n */\nexport const MEDIA_TEXT_SHADOW =\n \"0 2px 8px rgba(0,0,0,0.55), 0 6px 16px rgba(0,0,0,0.35)\";\n"
|
|
27
|
+
"content": "/** Internal monochrome render tokens for cinematic scenes and shared primitives. */\nimport type { TemplateStyle } from \"../template-context\";\n\n// ─── Canonical defaults (the only place these values are defined) ──\n\nexport const TOKEN_DEFAULTS = {\n /** Primary brand colour — CTA, highlights. */\n primary: \"#00E5A0\",\n /** Deepest background surface. */\n surface: \"#0A0A14\",\n /** Elevated card / panel surface. */\n surfaceElevated: \"#14152A\",\n /** Primary text color. */\n foreground: \"#FFFFFF\",\n /** Muted text — labels, footers, supporting copy. */\n muted: \"#A7A6B0\",\n /** Primary sans font family (first name of the stack). */\n font: \"Inter\",\n /** Script accent font family for handwritten callouts. */\n scriptFont: \"Caveat\",\n} as const;\n\n/**\n * The canonical template font stack: first family of the resolved brand font, backed by\n * OS-native sans fallbacks that render identically in preview and the\n * SVG-as-image export path.\n */\nexport function fontStack(styleFont: string | undefined): string {\n return `${(styleFont || \"\").split(\",\")[0].trim() || TOKEN_DEFAULTS.font}, -apple-system, BlinkMacSystemFont, \"Helvetica Neue\", Helvetica, Arial, sans-serif`;\n}\n\n// ─── Resolved tokens ───────────────────────────────────────────────\n\n// ─── Style presets ─────────────────────────────────────────────────\n\n/**\n * Background families a preset can pick. Each resolves to a pure CSS\n * background string in `gradientBackground` — no CSS `filter`, which the\n * SVG export path cannot rasterize.\n */\nexport type BackgroundFamily = \"mesh\" | \"wash\" | \"spotlight\";\n\n/** Title placement a preset defaults to (SceneFrameVariant, minus no-title). */\nexport type PresetTitlePlacement = \"title-top\" | \"title-center\";\n\nexport interface TypeTreatment {\n /** Added to the computed fontWeight (clamped 100–900). */\n weightDelta: number;\n /** Added to the size role's letterSpacing, in em. */\n trackingDeltaEm: number;\n /** Multiplies the computed fontSize. */\n sizeScale: number;\n /** Applied as CSS text-transform when set. */\n transform?: \"uppercase\";\n /**\n * Multiplies every text-archetype entrance/exit phase duration. Set by\n * `resolveTokens` from `style.motion`; absent on the raw preset literals,\n * where it reads as 1.\n *\n * It rides on the type treatment because that object is the one channel\n * that already flows from `style` into every `<TemplateText>` — 17 call\n * sites pass `resolveTokens(style).preset.type` and nothing else\n * style-derived. Pacing of typographic motion is part of how the type is\n * treated, so this isn't a smuggled payload.\n */\n phaseScale?: number;\n}\n\nexport interface StylePreset {\n id: string;\n /** Agent-facing use-when — surfaced in the registry index. */\n useWhen: string;\n background: BackgroundFamily;\n titlePlacement: PresetTitlePlacement;\n type: TypeTreatment;\n}\n\n/**\n * The named looks. `bold` is the default and is a deliberate no-op: a config\n * with no `preset` resolves to it and renders byte-identically to the\n * pre-preset output, so adding presets can't restyle anyone's existing video.\n *\n * Deliberately small. Every preset multiplies the QA surface by every\n * template at both orientations — grow this only when a brief can't be\n * expressed by the ones here.\n */\nexport const STYLE_PRESETS: Record<string, StylePreset> = {\n bold: {\n id: \"bold\",\n useWhen:\n \"The default. Drifting two-color brand mesh, heavy tight headlines at the top. Launches, hype, product moments — the loudest of the three.\",\n background: \"mesh\",\n titlePlacement: \"title-top\",\n type: { weightDelta: 0, trackingDeltaEm: 0, sizeScale: 1 },\n },\n editorial: {\n id: \"editorial\",\n useWhen:\n \"Calm vertical wash, lighter and wider-tracked headlines, centered. Reviews, thoughtful updates, premium or B2B brands — when the copy should feel considered rather than shouted.\",\n background: \"wash\",\n titlePlacement: \"title-center\",\n type: { weightDelta: -200, trackingDeltaEm: 0.01, sizeScale: 1.08 },\n },\n stark: {\n id: \"stark\",\n useWhen:\n \"Single hard spotlight on near-black, uppercase and tightly tracked. Dev tools, technical claims, high-contrast statements — maximum weight on very few words.\",\n background: \"spotlight\",\n titlePlacement: \"title-top\",\n type: { weightDelta: 100, trackingDeltaEm: -0.01, sizeScale: 1, transform: \"uppercase\" },\n },\n};\n\nexport const DEFAULT_PRESET_ID = \"bold\";\nexport const PRESET_IDS = Object.keys(STYLE_PRESETS);\n\n/** Unknown/unset ids fall back to the default rather than throwing — a bad\n * preset should never be the reason a render fails. */\nexport function resolvePreset(id: string | undefined): StylePreset {\n return (id && STYLE_PRESETS[id]) || STYLE_PRESETS[DEFAULT_PRESET_ID];\n}\n\n// ─── Density & motion ──────────────────────────────────────────────\n//\n// Two dimensions orthogonal to the named preset. `preset` answers \"which\n// look\"; these answer \"how loud\". Splitting them is what lets \"make the\n// whole video more understated\" be one instruction instead of hand-tuning\n// every scene: they resolve into multipliers on levers that already reach\n// all 28 templates, so no template file knows they exist.\n//\n// Both default to `normal`, whose multipliers are all 1 — a config that sets\n// neither renders byte-identically to the pre-density output. Same invariant\n// the presets hold.\n\nexport type StyleDensity = \"airy\" | \"normal\" | \"packed\";\nexport type StyleMotion = \"calm\" | \"normal\" | \"punchy\";\n\nexport interface DensityScale {\n id: StyleDensity;\n /** Agent-facing use-when — surfaced in the registry index. */\n useWhen: string;\n /** Multiplies the headline font size (via the preset's `type.sizeScale`). */\n typeScale: number;\n /** Multiplies the frame's safe-zone insets — bigger insets, more air. */\n safeZoneScale: number;\n}\n\nexport interface MotionScale {\n id: StyleMotion;\n /** Agent-facing use-when — surfaced in the registry index. */\n useWhen: string;\n /** Multiplies every text-archetype entrance/exit phase duration. */\n phaseScale: number;\n}\n\nexport const DENSITY_SCALES: Record<StyleDensity, DensityScale> = {\n airy: {\n id: \"airy\",\n useWhen:\n \"Smaller headlines held further off the frame edges. Premium, considered, editorial — when the copy should have room to breathe.\",\n typeScale: 0.92,\n safeZoneScale: 1.3,\n },\n normal: {\n id: \"normal\",\n useWhen: \"The default. No change to type size or frame padding.\",\n typeScale: 1,\n safeZoneScale: 1,\n },\n packed: {\n id: \"packed\",\n useWhen:\n \"Bigger headlines pushed closer to the edges. Dense, urgent, information-heavy — when the frame should feel full.\",\n typeScale: 1.08,\n safeZoneScale: 0.8,\n },\n};\n\nexport const MOTION_SCALES: Record<StyleMotion, MotionScale> = {\n calm: {\n id: \"calm\",\n useWhen:\n \"Slower entrances and exits — text eases in rather than arriving. Founder stories, sober data, anything reflective.\",\n phaseScale: 1.4,\n },\n normal: {\n id: \"normal\",\n useWhen: \"The default. Archetype timings as authored.\",\n phaseScale: 1,\n },\n punchy: {\n id: \"punchy\",\n useWhen:\n \"Snappier entrances and exits — text lands fast and clears fast. Hype, launches, hot takes.\",\n phaseScale: 0.7,\n },\n};\n\nexport const DEFAULT_DENSITY_ID: StyleDensity = \"normal\";\nexport const DEFAULT_MOTION_ID: StyleMotion = \"normal\";\nexport const DENSITY_IDS = Object.keys(DENSITY_SCALES) as StyleDensity[];\nexport const MOTION_IDS = Object.keys(MOTION_SCALES) as StyleMotion[];\n\n/** Unknown/unset ids fall back to `normal`, same as `resolvePreset`. */\nexport function resolveDensity(id: string | undefined): DensityScale {\n return (id && DENSITY_SCALES[id as StyleDensity]) || DENSITY_SCALES[DEFAULT_DENSITY_ID];\n}\n\n/** Unknown/unset ids fall back to `normal`, same as `resolvePreset`. */\nexport function resolveMotion(id: string | undefined): MotionScale {\n return (id && MOTION_SCALES[id as StyleMotion]) || MOTION_SCALES[DEFAULT_MOTION_ID];\n}\n\nexport interface ResolvedTokens {\n /** Primary brand colour. */\n primary: string;\n /** Secondary brand colour. */\n secondary: string;\n /** Visual background, deliberately separate from semantic foreground colours. */\n background: { type: \"solid\"; color: string } | { type: \"gradient\"; colors: [string, string] };\n /** Deepest background surface. */\n surface: string;\n /** Elevated card / panel surface. */\n surfaceElevated: string;\n /** Primary foreground colour. */\n foreground: string;\n /** Muted text color. */\n muted: string;\n /** Full font fallback stack (see fontStack). */\n font: string;\n /** Script accent font family. */\n scriptFont: string;\n logoUrl?: string;\n name?: string;\n /**\n * Resolved style preset — frame-level look. Always set.\n *\n * `preset.type` is the composed treatment, not the raw preset literal:\n * `sizeScale` already carries the density multiplier and `phaseScale`\n * carries the motion one. Templates pass this straight to `<TemplateText>`,\n * which is how both dials reach every template without a template edit.\n */\n preset: StylePreset;\n /** Resolved density dial. Always set; `normal` when unset. */\n density: DensityScale;\n /** Resolved motion dial. Always set; `normal` when unset. */\n motion: MotionScale;\n}\n\nexport function resolveTokens(\n style: TemplateStyle,\n): ResolvedTokens {\n\n // Compose the two dials into the preset's type treatment here, once. Every\n // template already passes `preset.type` to <TemplateText>, so folding them\n // in at the resolver is what makes them bite everywhere with no template\n // edits. At `normal`/`normal` both multipliers are 1 and the object is\n // value-identical to the preset literal.\n const preset = resolvePreset(style.preset);\n const density = resolveDensity(style.density);\n const motion = resolveMotion(style.motion);\n const composedPreset: StylePreset = {\n ...preset,\n type: {\n ...preset.type,\n sizeScale: preset.type.sizeScale * density.typeScale,\n phaseScale: motion.phaseScale,\n },\n };\n\n return {\n primary: \"#FFFFFF\",\n secondary: \"#FFFFFF\",\n background: { type: \"solid\", color: \"#000000\" },\n surface: \"#000000\",\n surfaceElevated: \"#171717\",\n foreground: \"#FFFFFF\",\n muted: \"#B7B7BC\",\n font: '-apple-system, BlinkMacSystemFont, \"Helvetica Neue\", Roboto, Arial, sans-serif',\n scriptFont: \"Georgia\",\n preset: composedPreset,\n density,\n motion,\n };\n}\n\n// ─── Color math the resolver depends on ────────────────────────────\n// (Lives beside the resolver because theme owns both token resolution and\n// color derivation.)\n\n/**\n * Shift a hex color's hue by a number of degrees.\n * Used by internal template treatments that need a related hue.\n */\nexport function shiftHue(hex: string, degrees: number): string {\n const r = parseInt(hex.slice(1, 3), 16) / 255;\n const g = parseInt(hex.slice(3, 5), 16) / 255;\n const b = parseInt(hex.slice(5, 7), 16) / 255;\n\n const max = Math.max(r, g, b);\n const min = Math.min(r, g, b);\n const d = max - min;\n const l = (max + min) / 2;\n let h = 0;\n let s = 0;\n\n if (d > 0) {\n s = l > 0.5 ? d / (2 - max - min) : d / (max + min);\n if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;\n else if (max === g) h = ((b - r) / d + 2) / 6;\n else h = ((r - g) / d + 4) / 6;\n }\n\n h = (h + degrees / 360 + 1) % 1;\n\n const hue2rgb = (p: number, q: number, t: number) => {\n if (t < 0) t += 1;\n if (t > 1) t -= 1;\n if (t < 1 / 6) return p + (q - p) * 6 * t;\n if (t < 1 / 2) return q;\n if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;\n return p;\n };\n\n let r2: number, g2: number, b2: number;\n if (s === 0) {\n r2 = g2 = b2 = l;\n } else {\n const q = l < 0.5 ? l * (1 + s) : l + s - l * s;\n const p = 2 * l - q;\n r2 = hue2rgb(p, q, h + 1 / 3);\n g2 = hue2rgb(p, q, h);\n b2 = hue2rgb(p, q, h - 1 / 3);\n }\n\n const toHex = (v: number) =>\n Math.round(v * 255)\n .toString(16)\n .padStart(2, \"0\");\n return `#${toHex(r2)}${toHex(g2)}${toHex(b2)}`;\n}\n\n/** Lighten a hex color by a 0-1 factor. */\n/** Scale a hex color toward black by `factor` (0 = unchanged, 1 = black). */\nexport function darken(hex: string, factor: number): string {\n if (!hex.startsWith(\"#\") || (hex.length !== 4 && hex.length !== 7)) {\n return hex;\n }\n const full = hex.length === 4\n ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`\n : hex;\n const ch = (i: number) =>\n Math.max(0, Math.round(parseInt(full.slice(i, i + 2), 16) * (1 - factor)))\n .toString(16)\n .padStart(2, \"0\");\n return `#${ch(1)}${ch(3)}${ch(5)}`;\n}\n\nexport function lighten(hex: string, factor: number): string {\n if (!hex.startsWith(\"#\") || (hex.length !== 4 && hex.length !== 7)) {\n return hex;\n }\n const full = hex.length === 4\n ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`\n : hex;\n const r = parseInt(full.slice(1, 3), 16);\n const g = parseInt(full.slice(3, 5), 16);\n const b = parseInt(full.slice(5, 7), 16);\n const lr = Math.min(255, Math.round(r + (255 - r) * factor));\n const lg = Math.min(255, Math.round(g + (255 - g) * factor));\n const lb = Math.min(255, Math.round(b + (255 - b) * factor));\n return `#${lr.toString(16).padStart(2, \"0\")}${lg.toString(16).padStart(2, \"0\")}${lb.toString(16).padStart(2, \"0\")}`;\n}\n\n/**\n * Per-glyph halo for type sitting directly on photo or video.\n *\n * A frame-wide scrim can only trade picture for contrast, and it loses that\n * trade against blown-out highlights: holding white type at 4.5:1 over a\n * near-white region needs ~0.8 alpha of black across the whole plate. A\n * two-layer shadow buys the same local separation for free — a tight 8px pass\n * for edge definition against fine texture, a wide 16px pass for the soft\n * falloff that separates the word from whatever sits behind it. Export-safe:\n * `text-shadow` survives SVG capture, `filter`/`backdrop-filter` do not.\n */\nexport const MEDIA_TEXT_SHADOW =\n \"0 2px 8px rgba(0,0,0,0.55), 0 6px 16px rgba(0,0,0,0.35)\";\n"
|
|
28
28
|
}
|
|
29
29
|
],
|
|
30
30
|
"meta": {
|
|
@@ -43,32 +43,30 @@ Restart the development server after changing keys. Open
|
|
|
43
43
|
|
|
44
44
|
## How a response is made
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
| Templates only | 0 | Rendered locally |
|
|
71
|
-
| Full AI video | 5 | Every beat generated |
|
|
46
|
+
The planner streams a short spoken hook, then complete narrated scenes. When a
|
|
47
|
+
generated provider is configured, an early shot can start generating while the
|
|
48
|
+
rest of the story is planned. The director chooses footage or one of eight
|
|
49
|
+
editorial templates for each beat; there is one cinematic mode.
|
|
50
|
+
|
|
51
|
+
Playback starts after a contiguous preparation cushion, or after a shorter
|
|
52
|
+
complete response is ready. Media is decoded before use. At a late video cut,
|
|
53
|
+
the player holds its clock and narration until a usable frame appears, with a
|
|
54
|
+
bounded error path. The first hook can play during preparation.
|
|
55
|
+
|
|
56
|
+
`mediaSource: "generate"` requests a distinctive illustrative shot;
|
|
57
|
+
`mediaSource: "stock"` requests approved footage. Generation attempts, including
|
|
58
|
+
failures, share the host's `maxGeneratedVideos` ceiling. A stock miss never
|
|
59
|
+
broadens the subject automatically. A failed full-bleed scene uses its grounded
|
|
60
|
+
`fallbackText` as a chapter card.
|
|
61
|
+
|
|
62
|
+
## Reviewed stock
|
|
63
|
+
|
|
64
|
+
The starter's `approvedStock` index in `stock.ts` is deliberately empty. Add an
|
|
65
|
+
asset only after inspecting its content, poster and allowed orientations. List
|
|
66
|
+
literal matching queries and a reviewed description. Add those available
|
|
67
|
+
queries to your host instructions so the planner can choose them when relevant.
|
|
68
|
+
An unreviewed query returns `null` without a network search or unrelated result.
|
|
69
|
+
The host owns licensing, storage, clip rendition and retention.
|
|
72
70
|
|
|
73
71
|
The provider names its own model. Override the tested defaults with
|
|
74
72
|
`ANTHROPIC_PLANNER_MODEL`, `ANTHROPIC_NARRATION_MODEL`, or `FAL_VIDEO_MODEL`
|
|
@@ -8,16 +8,17 @@ async function filmScene(
|
|
|
8
8
|
generatedLook: string | undefined,
|
|
9
9
|
orientation: "portrait" | "landscape",
|
|
10
10
|
signal: AbortSignal,
|
|
11
|
+
shotDirection?: string,
|
|
11
12
|
) {
|
|
12
13
|
fal.config({ credentials: process.env.FAL_KEY });
|
|
13
14
|
const started = Date.now();
|
|
14
|
-
console.log(`[video-chat] filming: ${subject.slice(0, 70)}`);
|
|
15
15
|
try {
|
|
16
16
|
const result = await fal.subscribe(VIDEO_MODEL, {
|
|
17
17
|
input: {
|
|
18
18
|
prompt: [
|
|
19
|
-
`
|
|
20
|
-
"
|
|
19
|
+
`Cinematic shot, ${orientation === "portrait" ? "9:16 vertical" : "16:9"}. ${subject}.`,
|
|
20
|
+
"Use a steady composition or one restrained camera move. Physically plausible motion.",
|
|
21
|
+
shotDirection,
|
|
21
22
|
"No on-screen text, captions, subtitles, watermarks or logos.",
|
|
22
23
|
"Diegetic sound only. No music, no voiceover.",
|
|
23
24
|
generatedLook,
|
|
@@ -44,8 +45,8 @@ async function filmScene(
|
|
|
44
45
|
/** Optional generated video and transcription; credentials remain server-only. */
|
|
45
46
|
export const videoProvider: Pick<VideoChatHandlerOptions, "generateVideo" | "transcribe"> = {
|
|
46
47
|
generateVideo: process.env.FAL_KEY
|
|
47
|
-
? async (query, { generatedLook, orientation, signal }) => ({
|
|
48
|
-
url: await filmScene(query, generatedLook, orientation, signal),
|
|
48
|
+
? async (query, { generatedLook, orientation, signal, scene }) => ({
|
|
49
|
+
url: await filmScene(query, generatedLook, orientation, signal, typeof scene?.variables.shotDirection === "string" ? scene.variables.shotDirection : undefined),
|
|
49
50
|
type: "video" as const,
|
|
50
51
|
})
|
|
51
52
|
: undefined,
|