@vanillaskyai/video 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/{builtin-server-HW6HGXCZ.js → builtin-server-W4PLJUZ5.js} +2 -2
  3. package/dist/check-runtime.js +2 -2
  4. package/dist/{chunk-UDIODEDK.js → chunk-224QNWRA.js} +5 -1
  5. package/dist/{chunk-OR52UMSF.js → chunk-2HFO22K2.js} +2 -1
  6. package/dist/{chunk-MXEYPK7M.js → chunk-4ZJLPHBV.js} +194 -62
  7. package/dist/{chunk-3ZTEFJIR.js → chunk-ELVOT2FQ.js} +1 -1
  8. package/dist/{chunk-LH4BPALA.js → chunk-IIN5M5HW.js} +194 -61
  9. package/dist/{chunk-MQILEEXC.js → chunk-IR44XKBI.js} +1 -1
  10. package/dist/{chunk-G7IZBBWF.js → chunk-NMFDC2W2.js} +3 -10
  11. package/dist/chunk-XX4GXKTX.js +35 -0
  12. package/dist/chunk-Z67Q5KUW.js +16 -0
  13. package/dist/{cinema-media-YQHSIFDA.js → cinema-media-C7OUPNSH.js} +3 -2
  14. package/dist/cli.js +3 -3
  15. package/dist/{comparison-RZMMHZR2.js → comparison-GNZOR7MF.js} +14 -4
  16. package/dist/{editorial-timeline-OZX7OS6G.js → editorial-timeline-EMV5335S.js} +10 -3
  17. package/dist/key-figure-P37VK425.js +30 -0
  18. package/dist/{mobile-message-UZUPIKHD.js → mobile-message-OF7T62B7.js} +3 -2
  19. package/dist/{preload-media-CFTZIMZL.js → preload-media-LJXWKTWG.js} +1 -1
  20. package/dist/quote-T6WYLN7A.js +33 -0
  21. package/dist/react.js +29 -27
  22. package/dist/server.js +97 -19
  23. package/dist/template-catalog.d.ts +154 -19
  24. package/dist/template-catalog.js +1 -1
  25. package/dist/test.js +1 -1
  26. package/docs/customization.md +1 -1
  27. package/docs/maintainers/cinematic-migration.md +4 -0
  28. package/docs/media-and-audio.md +3 -3
  29. package/docs/provider-integration.md +1 -1
  30. package/package.json +1 -1
  31. package/registry/items/comparison.json +71 -4
  32. package/registry/items/editorialTimeline.json +71 -4
  33. package/registry/items/keyFigure.json +69 -4
  34. package/registry/items/quote.json +71 -4
  35. package/starters/video-chat/package.json +1 -1
  36. package/dist/focus-cards-TJ72BT7U.js +0 -20
  37. package/dist/key-figure-GYENXKH3.js +0 -20
  38. package/dist/quote-EW5HQA6H.js +0 -23
  39. package/registry/items/focusCards.json +0 -90
@@ -3,11 +3,13 @@
3
3
  "name": "comparison",
4
4
  "type": "registry:block",
5
5
  "title": "Comparison",
6
- "description": "Two short statements with equal weight on black, side by side or stacked in portrait.",
6
+ "description": "Two short statements with equal weight over relevant media or black, side by side or stacked in portrait.",
7
7
  "dependencies": [
8
8
  "react"
9
9
  ],
10
10
  "registryDependencies": [
11
+ "@vanillasky/backgrounds",
12
+ "@vanillasky/motion",
11
13
  "@vanillasky/template-context",
12
14
  "@vanillasky/theme"
13
15
  ],
@@ -16,7 +18,7 @@
16
18
  "path": "src/visual-system/scene-templates/comparison.tsx",
17
19
  "type": "registry:component",
18
20
  "target": "vanillasky/scene-templates/comparison.tsx",
19
- "content": "import type {SceneTemplateProps} from './types';\nimport {editorialFont,editorialLabel,fade} from './editorial-typography';\n\n/** Two concise alternatives, with equal visual weight and a quiet sequential reveal. */\nfunction ComparisonScene({variables,width,height,progress,motionProgress=progress,safeZone}:SceneTemplateProps){\n const portrait=height>width,u=Math.min(width,height);\n const side=Math.max(width*.12,safeZone.left,safeZone.right);\n const top=Math.max(height*.2,safeZone.top),bottom=height-Math.max(height*.23,safeZone.bottom);\n const area=bottom-top,cy=(top+bottom)/2,available=width-side*2;\n const entries=[{label:String(variables.leftLabel??''),text:String(variables.leftText??'')},{label:String(variables.rightLabel??''),text:String(variables.rightText??'')}];\n const textWidth=portrait?available:available*.39;\n return <div data-template=\"comparison\" style={{position:'absolute',inset:0,background:'#000',color:'#fff',fontFamily:editorialFont,overflow:'hidden'}}>\n <div aria-hidden=\"true\" style={{position:'absolute',left:portrait?width*.32:width/2,top:portrait?cy:top+area*.18,width:portrait?width*.36:u*.001,height:portrait?u*.001:area*.64,background:'#fff',opacity:.22*fade((motionProgress-.16)/.18)}}/>\n {entries.map((entry,i)=><div key={i} data-comparison-side={i===0?'left':'right'} style={{position:'absolute',left:portrait?side:side+available*(i===0?.055:.555),top:portrait?top+area*(i===0?.25:.75):cy,width:textWidth,transform:'translateY(-50%)',textAlign:'center',opacity:fade((motionProgress-.04-i*.22)/.2)}}>\n {entry.label&&<div style={{...editorialLabel(u),marginBottom:u*.028}}>{entry.label}</div>}\n <div style={{fontSize:u*.058,fontWeight:500,lineHeight:1.22,letterSpacing:'-.025em',textWrap:'balance',overflowWrap:'anywhere'}}>{entry.text}</div>\n </div>)}\n </div>;\n}\n\nexport const ComparisonSceneTemplate = ComparisonScene;\n"
21
+ "content": "import {EditorialSurface} from './editorial-background';\nimport {hasSceneMedia} from './media-source';\nimport type {SceneTemplateProps} from './types';\nimport {editorialLabel,fade} from './editorial-typography';\n\n/** Two concise alternatives, with equal visual weight and a quiet sequential reveal. */\nfunction ComparisonScene(props:SceneTemplateProps){\n const {variables,width,height,progress,motionProgress=progress,safeZone}=props;\n const overMedia=hasSceneMedia(variables);\n const portrait=height>width,u=Math.min(width,height);\n const side=Math.max(width*.12,safeZone.left,safeZone.right);\n const top=Math.max(height*.2,safeZone.top),bottom=height-Math.max(height*.23,safeZone.bottom);\n const area=bottom-top,cy=(top+bottom)/2,available=width-side*2;\n const entries=[{label:String(variables.leftLabel??''),text:String(variables.leftText??'')},{label:String(variables.rightLabel??''),text:String(variables.rightText??'')}];\n const textWidth=portrait?available:available*.39;\n return <EditorialSurface {...props} template=\"comparison\">\n <div aria-hidden=\"true\" style={{position:'absolute',left:portrait?width*.32:width/2,top:portrait?cy:top+area*.18,width:portrait?width*.36:u*.001,height:portrait?u*.001:area*.64,background:'#fff',opacity:.22*fade((motionProgress-.16)/.18)}}/>\n {entries.map((entry,i)=><div key={i} data-comparison-side={i===0?'left':'right'} style={{position:'absolute',left:portrait?side:side+available*(i===0?.055:.555),top:portrait?top+area*(i===0?.25:.75):cy,width:textWidth,transform:'translateY(-50%)',textAlign:'center',opacity:fade((motionProgress-.04-i*.22)/.2)}}>\n {entry.label&&<div style={{...editorialLabel(u),color:overMedia?'#fff':'#b7b7bc',marginBottom:u*.028}}>{entry.label}</div>}\n <div style={{fontSize:u*.058,fontWeight:500,lineHeight:1.22,letterSpacing:'-.025em',textWrap:'balance',overflowWrap:'anywhere'}}>{entry.text}</div>\n </div>)}\n </EditorialSurface>;\n}\n\nexport const ComparisonSceneTemplate = ComparisonScene;\n"
20
22
  },
21
23
  {
22
24
  "path": "src/visual-system/scene-templates/types.ts",
@@ -29,6 +31,24 @@
29
31
  "type": "registry:component",
30
32
  "target": "vanillasky/scene-templates/editorial-typography.ts",
31
33
  "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"
34
+ },
35
+ {
36
+ "path": "src/visual-system/scene-templates/editorial-background.tsx",
37
+ "type": "registry:component",
38
+ "target": "vanillasky/scene-templates/editorial-background.tsx",
39
+ "content": "import type {CSSProperties, ReactNode} from 'react';\nimport type {SceneTemplateProps} from './types';\nimport {SceneBackground, getMediaBackgroundProps, hasSceneMedia} from './scene-background';\nimport {editorialFont} from './editorial-typography';\n\n/** Optional host-resolved footage, sharing the player's decode and playback plane. */\nexport function EditorialSurface({children,template,...props}:SceneTemplateProps & {children:ReactNode;template:string}) {\n const {variables,style,width,height,progress,sceneDuration,isPlaying}=props;\n const overMedia=hasSceneMedia(variables),u=Math.min(width,height);\n const surface:CSSProperties={position:'absolute',inset:0,overflow:'hidden',background:overMedia?'var(--vanillasky-template-surface, #000)':'#000',fontFamily:editorialFont,color:'#fff',\n // Two soft, scale-aware shadows preserve local contrast without outlining type.\n textShadow:overMedia?`0 ${u*.002}px ${u*.008}px rgba(0,0,0,.65), 0 ${u*.006}px ${u*.016}px rgba(0,0,0,.4)`:undefined};\n return <div data-template={template} style={surface}>\n {overMedia&&<SceneBackground style={style} width={width} height={height} progress={progress} sceneDuration={sceneDuration} {...getMediaBackgroundProps(variables)} mediaTreatment=\"cinematic\" textAnchor=\"center\" backgroundEffect=\"none\" isPlaying={isPlaying}/>}\n {children}\n </div>;\n}\n"
40
+ },
41
+ {
42
+ "path": "src/visual-system/scene-templates/scene-background.tsx",
43
+ "type": "registry:component",
44
+ "target": "vanillasky/scene-templates/scene-background.tsx",
45
+ "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"
46
+ },
47
+ {
48
+ "path": "src/visual-system/scene-templates/media-source.ts",
49
+ "type": "registry:component",
50
+ "target": "vanillasky/scene-templates/media-source.ts",
51
+ "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"
32
52
  }
33
53
  ],
34
54
  "meta": {
@@ -36,7 +56,7 @@
36
56
  "layer": "template",
37
57
  "tier": "free",
38
58
  "label": "Comparison",
39
- "description": "Two short statements with equal weight on black, side by side or stacked in portrait.",
59
+ "description": "Two short statements with equal weight over relevant media or black, side by side or stacked in portrait.",
40
60
  "usesGlobalTextEffect": false,
41
61
  "usesGlobalTransition": false,
42
62
  "usesGlobalBackgroundEffect": false,
@@ -88,13 +108,60 @@
88
108
  "maxLength": 60,
89
109
  "default": "More room to think",
90
110
  "description": "Second real alternative, parallel to the first."
111
+ },
112
+ "mediaKeyword": {
113
+ "type": "string",
114
+ "format": "stock-media-keyword",
115
+ "minLength": 1,
116
+ "maxLength": 80,
117
+ "description": "2–8 word literal subject/action search intent, maximum 80 characters. Host resolves URLs.",
118
+ "examples": [
119
+ "Ocean waves breaking on a rocky shore"
120
+ ]
121
+ },
122
+ "mediaUrl": {
123
+ "type": "string",
124
+ "format": "uri",
125
+ "description": "Host-only approved photo or video URL.",
126
+ "default": ""
127
+ },
128
+ "mediaType": {
129
+ "type": "string",
130
+ "enum": [
131
+ "photo",
132
+ "video"
133
+ ],
134
+ "default": "video",
135
+ "description": "Host-resolved asset kind."
136
+ },
137
+ "mediaPoster": {
138
+ "type": "string",
139
+ "format": "uri",
140
+ "description": "Host-only approved poster URL for video decoding.",
141
+ "default": ""
142
+ },
143
+ "shotDirection": {
144
+ "type": "string",
145
+ "maxLength": 220,
146
+ "description": "Optional action, framing and continuity for generation. Preserve the subject; do not request rendered text."
147
+ },
148
+ "mediaSource": {
149
+ "type": "string",
150
+ "enum": [
151
+ "generate",
152
+ "stock"
153
+ ],
154
+ "description": "Generate distinctive illustrative shots; use approved stock for familiar observable subjects."
91
155
  }
92
156
  },
93
157
  "required": [
94
158
  "leftText",
95
159
  "rightText"
96
160
  ],
97
- "additionalProperties": false
161
+ "additionalProperties": false,
162
+ "x-vanillasky": {
163
+ "allowsStockMedia": true
164
+ }
98
165
  }
99
166
  }
100
167
  }
@@ -3,11 +3,13 @@
3
3
  "name": "editorialTimeline",
4
4
  "type": "registry:block",
5
5
  "title": "Timeline",
6
- "description": "Three to five events on a fine continuous line on black; without dates.",
6
+ "description": "Three to five events on a fine continuous line over relevant media or black; without dates.",
7
7
  "dependencies": [
8
8
  "react"
9
9
  ],
10
10
  "registryDependencies": [
11
+ "@vanillasky/backgrounds",
12
+ "@vanillasky/motion",
11
13
  "@vanillasky/template-context",
12
14
  "@vanillasky/theme"
13
15
  ],
@@ -16,7 +18,7 @@
16
18
  "path": "src/visual-system/scene-templates/editorial-timeline.tsx",
17
19
  "type": "registry:component",
18
20
  "target": "vanillasky/scene-templates/editorial-timeline.tsx",
19
- "content": "import type {SceneTemplateProps} from './types';\nimport {editorialFont,fade} from './editorial-typography';\nexport const timelineTiming=(index:number)=>({nodeStart:index===0?.06:.23+(index-1)*.11,lineStart:.14+index*.11,lineDuration:.09,entranceDuration:.16});\n\n/** A fine line establishes order; labels carry the sequence without dates. */\nfunction TimelineScene({variables,progress,motionProgress=progress,width,height,safeZone}:SceneTemplateProps){\n const events=(Array.isArray(variables.events)?variables.events:[]).slice(0,5).map(e=>({label:String(e?.label??'')}));\n const portrait=height>width,u=Math.min(width,height),n=Math.max(1,events.length);\n const side=Math.max(width*.13,safeZone.left,safeZone.right),available=width-side*2;\n const top=Math.max(height*.2,safeZone.top),bottom=height-Math.max(height*.24,safeZone.bottom);\n const slot=portrait?(bottom-top)/n:available/n;\n const points=events.map((_,i)=>({x:portrait?side:side+slot*(i+.5),y:portrait?top+slot*(i+.5):height*.43}));\n const radius=u*.0045;\n return <div data-template=\"timeline\" style={{position:'absolute',inset:0,overflow:'hidden',background:'#000',fontFamily:editorialFont,color:'#fff'}}>\n {points.slice(0,-1).map((point,i)=>{\n const next=points[i+1],timing=timelineTiming(i),line=fade((motionProgress-timing.lineStart)/timing.lineDuration);\n return <div key={`line-${i}`} data-step-connector=\"true\" style={{position:'absolute',left:point.x,top:point.y,width:portrait?u*.001:next.x-point.x,height:portrait?next.y-point.y:u*.001,background:'#fff',opacity:.3,transform:portrait?`scaleY(${line})`:`scaleX(${line})`,transformOrigin:portrait?'top':'left'}}/>;\n })}\n {points.map((point,i)=>{\n const timing=timelineTiming(i),event=events[i],opacity=fade((motionProgress-timing.nodeStart)/timing.entranceDuration);\n const textW=portrait?available-u*.055:slot*.86;\n return <div key={i} data-template-item=\"steps\" style={{opacity}}>\n <div aria-hidden=\"true\" style={{position:'absolute',left:point.x-radius,top:point.y-radius,width:radius*2,height:radius*2,borderRadius:'50%',background:'#fff'}}/>\n <div style={{position:'absolute',left:portrait?point.x+u*.055:point.x-textW/2,top:portrait?point.y:point.y+u*.05,width:textW,transform:portrait?'translateY(-50%)':undefined,textAlign:portrait?'left':'center'}}>\n <div style={{fontSize:u*(portrait?.052:.041),fontWeight:500,lineHeight:1.22,letterSpacing:'-.025em',textWrap:'balance',overflowWrap:'anywhere'}}>{event.label}</div>\n </div>\n </div>;\n })}\n </div>;\n}\n\nexport const TimelineSceneTemplate = TimelineScene;\n"
21
+ "content": "import {EditorialSurface} from './editorial-background';\nimport type {SceneTemplateProps} from './types';\nimport {fade} from './editorial-typography';\nexport const timelineTiming=(index:number)=>({nodeStart:index===0?.06:.23+(index-1)*.11,lineStart:.14+index*.11,lineDuration:.09,entranceDuration:.16});\n\n/** A fine line establishes order; labels carry the sequence without dates. */\nfunction TimelineScene(props:SceneTemplateProps){\n const {variables,progress,motionProgress=progress,width,height,safeZone}=props;\n const events=(Array.isArray(variables.events)?variables.events:[]).slice(0,5).map(e=>({label:String(e?.label??'')}));\n const portrait=height>width,u=Math.min(width,height),n=Math.max(1,events.length);\n const side=Math.max(width*.13,safeZone.left,safeZone.right),available=width-side*2;\n const top=Math.max(height*.2,safeZone.top),bottom=height-Math.max(height*.24,safeZone.bottom);\n const slot=portrait?(bottom-top)/n:available/n;\n const points=events.map((_,i)=>({x:portrait?side:side+slot*(i+.5),y:portrait?top+slot*(i+.5):height*.43}));\n const radius=u*.0045;\n return <EditorialSurface {...props} template=\"timeline\">\n {points.slice(0,-1).map((point,i)=>{\n const next=points[i+1],timing=timelineTiming(i),line=fade((motionProgress-timing.lineStart)/timing.lineDuration);\n return <div key={`line-${i}`} data-step-connector=\"true\" style={{position:'absolute',left:point.x,top:point.y,width:portrait?u*.001:next.x-point.x,height:portrait?next.y-point.y:u*.001,background:'#fff',opacity:.3,transform:portrait?`scaleY(${line})`:`scaleX(${line})`,transformOrigin:portrait?'top':'left'}}/>;\n })}\n {points.map((point,i)=>{\n const timing=timelineTiming(i),event=events[i],opacity=fade((motionProgress-timing.nodeStart)/timing.entranceDuration);\n const textW=portrait?available-u*.055:slot*.86;\n return <div key={i} data-template-item=\"steps\" style={{opacity}}>\n <div aria-hidden=\"true\" style={{position:'absolute',left:point.x-radius,top:point.y-radius,width:radius*2,height:radius*2,borderRadius:'50%',background:'#fff'}}/>\n <div style={{position:'absolute',left:portrait?point.x+u*.055:point.x-textW/2,top:portrait?point.y:point.y+u*.05,width:textW,transform:portrait?'translateY(-50%)':undefined,textAlign:portrait?'left':'center'}}>\n <div style={{fontSize:u*(portrait?.052:.041),fontWeight:500,lineHeight:1.22,letterSpacing:'-.025em',textWrap:'balance',overflowWrap:'anywhere'}}>{event.label}</div>\n </div>\n </div>;\n })}\n </EditorialSurface>;\n}\n\nexport const TimelineSceneTemplate = TimelineScene;\n"
20
22
  },
21
23
  {
22
24
  "path": "src/visual-system/scene-templates/types.ts",
@@ -29,6 +31,24 @@
29
31
  "type": "registry:component",
30
32
  "target": "vanillasky/scene-templates/editorial-typography.ts",
31
33
  "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"
34
+ },
35
+ {
36
+ "path": "src/visual-system/scene-templates/editorial-background.tsx",
37
+ "type": "registry:component",
38
+ "target": "vanillasky/scene-templates/editorial-background.tsx",
39
+ "content": "import type {CSSProperties, ReactNode} from 'react';\nimport type {SceneTemplateProps} from './types';\nimport {SceneBackground, getMediaBackgroundProps, hasSceneMedia} from './scene-background';\nimport {editorialFont} from './editorial-typography';\n\n/** Optional host-resolved footage, sharing the player's decode and playback plane. */\nexport function EditorialSurface({children,template,...props}:SceneTemplateProps & {children:ReactNode;template:string}) {\n const {variables,style,width,height,progress,sceneDuration,isPlaying}=props;\n const overMedia=hasSceneMedia(variables),u=Math.min(width,height);\n const surface:CSSProperties={position:'absolute',inset:0,overflow:'hidden',background:overMedia?'var(--vanillasky-template-surface, #000)':'#000',fontFamily:editorialFont,color:'#fff',\n // Two soft, scale-aware shadows preserve local contrast without outlining type.\n textShadow:overMedia?`0 ${u*.002}px ${u*.008}px rgba(0,0,0,.65), 0 ${u*.006}px ${u*.016}px rgba(0,0,0,.4)`:undefined};\n return <div data-template={template} style={surface}>\n {overMedia&&<SceneBackground style={style} width={width} height={height} progress={progress} sceneDuration={sceneDuration} {...getMediaBackgroundProps(variables)} mediaTreatment=\"cinematic\" textAnchor=\"center\" backgroundEffect=\"none\" isPlaying={isPlaying}/>}\n {children}\n </div>;\n}\n"
40
+ },
41
+ {
42
+ "path": "src/visual-system/scene-templates/scene-background.tsx",
43
+ "type": "registry:component",
44
+ "target": "vanillasky/scene-templates/scene-background.tsx",
45
+ "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"
46
+ },
47
+ {
48
+ "path": "src/visual-system/scene-templates/media-source.ts",
49
+ "type": "registry:component",
50
+ "target": "vanillasky/scene-templates/media-source.ts",
51
+ "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"
32
52
  }
33
53
  ],
34
54
  "meta": {
@@ -36,7 +56,7 @@
36
56
  "layer": "template",
37
57
  "tier": "free",
38
58
  "label": "Timeline",
39
- "description": "Three to five events on a fine continuous line on black; without dates.",
59
+ "description": "Three to five events on a fine continuous line over relevant media or black; without dates.",
40
60
  "usesGlobalTextEffect": false,
41
61
  "usesGlobalTransition": false,
42
62
  "usesGlobalBackgroundEffect": false,
@@ -93,12 +113,59 @@
93
113
  }
94
114
  ],
95
115
  "description": "3–5 ordered events or actions, each with one short label; no dates."
116
+ },
117
+ "mediaKeyword": {
118
+ "type": "string",
119
+ "format": "stock-media-keyword",
120
+ "minLength": 1,
121
+ "maxLength": 80,
122
+ "description": "2–8 word literal subject/action search intent, maximum 80 characters. Host resolves URLs.",
123
+ "examples": [
124
+ "Ocean waves breaking on a rocky shore"
125
+ ]
126
+ },
127
+ "mediaUrl": {
128
+ "type": "string",
129
+ "format": "uri",
130
+ "description": "Host-only approved photo or video URL.",
131
+ "default": ""
132
+ },
133
+ "mediaType": {
134
+ "type": "string",
135
+ "enum": [
136
+ "photo",
137
+ "video"
138
+ ],
139
+ "default": "video",
140
+ "description": "Host-resolved asset kind."
141
+ },
142
+ "mediaPoster": {
143
+ "type": "string",
144
+ "format": "uri",
145
+ "description": "Host-only approved poster URL for video decoding.",
146
+ "default": ""
147
+ },
148
+ "shotDirection": {
149
+ "type": "string",
150
+ "maxLength": 220,
151
+ "description": "Optional action, framing and continuity for generation. Preserve the subject; do not request rendered text."
152
+ },
153
+ "mediaSource": {
154
+ "type": "string",
155
+ "enum": [
156
+ "generate",
157
+ "stock"
158
+ ],
159
+ "description": "Generate distinctive illustrative shots; use approved stock for familiar observable subjects."
96
160
  }
97
161
  },
98
162
  "required": [
99
163
  "events"
100
164
  ],
101
- "additionalProperties": false
165
+ "additionalProperties": false,
166
+ "x-vanillasky": {
167
+ "allowsStockMedia": true
168
+ }
102
169
  }
103
170
  }
104
171
  }
@@ -3,11 +3,13 @@
3
3
  "name": "keyFigure",
4
4
  "type": "registry:block",
5
5
  "title": "Key figure",
6
- "description": "One supplied figure with one short label on black.",
6
+ "description": "One supplied figure with one short label over relevant media or black.",
7
7
  "dependencies": [
8
8
  "react"
9
9
  ],
10
10
  "registryDependencies": [
11
+ "@vanillasky/backgrounds",
12
+ "@vanillasky/motion",
11
13
  "@vanillasky/template-context",
12
14
  "@vanillasky/theme"
13
15
  ],
@@ -16,7 +18,7 @@
16
18
  "path": "src/visual-system/scene-templates/key-figure.tsx",
17
19
  "type": "registry:component",
18
20
  "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"
21
+ "content": "import {EditorialSurface} from './editorial-background';\nimport {hasSceneMedia} from './media-source';\nimport type {SceneTemplateProps} from './types';\nimport {editorialLabel,fade} from './editorial-typography';\n\n/** One supplied figure, shown directly without a count-up that invents intermediate values. */\nfunction KeyFigureScene(props:SceneTemplateProps){\n const {variables,width,height,progress,motionProgress=progress,safeZone}=props;\n const overMedia=hasSceneMedia(variables);\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 <EditorialSurface {...props} template=\"keyFigure\">\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),color:overMedia?'#fff':'#b7b7bc',marginTop:u*.045,textWrap:'balance',overflowWrap:'anywhere'}}>{String(variables.label??'')}</div>\n </div>\n </EditorialSurface>;\n}\n\nexport const KeyFigureSceneTemplate = KeyFigureScene;\n"
20
22
  },
21
23
  {
22
24
  "path": "src/visual-system/scene-templates/types.ts",
@@ -29,6 +31,24 @@
29
31
  "type": "registry:component",
30
32
  "target": "vanillasky/scene-templates/editorial-typography.ts",
31
33
  "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"
34
+ },
35
+ {
36
+ "path": "src/visual-system/scene-templates/editorial-background.tsx",
37
+ "type": "registry:component",
38
+ "target": "vanillasky/scene-templates/editorial-background.tsx",
39
+ "content": "import type {CSSProperties, ReactNode} from 'react';\nimport type {SceneTemplateProps} from './types';\nimport {SceneBackground, getMediaBackgroundProps, hasSceneMedia} from './scene-background';\nimport {editorialFont} from './editorial-typography';\n\n/** Optional host-resolved footage, sharing the player's decode and playback plane. */\nexport function EditorialSurface({children,template,...props}:SceneTemplateProps & {children:ReactNode;template:string}) {\n const {variables,style,width,height,progress,sceneDuration,isPlaying}=props;\n const overMedia=hasSceneMedia(variables),u=Math.min(width,height);\n const surface:CSSProperties={position:'absolute',inset:0,overflow:'hidden',background:overMedia?'var(--vanillasky-template-surface, #000)':'#000',fontFamily:editorialFont,color:'#fff',\n // Two soft, scale-aware shadows preserve local contrast without outlining type.\n textShadow:overMedia?`0 ${u*.002}px ${u*.008}px rgba(0,0,0,.65), 0 ${u*.006}px ${u*.016}px rgba(0,0,0,.4)`:undefined};\n return <div data-template={template} style={surface}>\n {overMedia&&<SceneBackground style={style} width={width} height={height} progress={progress} sceneDuration={sceneDuration} {...getMediaBackgroundProps(variables)} mediaTreatment=\"cinematic\" textAnchor=\"center\" backgroundEffect=\"none\" isPlaying={isPlaying}/>}\n {children}\n </div>;\n}\n"
40
+ },
41
+ {
42
+ "path": "src/visual-system/scene-templates/scene-background.tsx",
43
+ "type": "registry:component",
44
+ "target": "vanillasky/scene-templates/scene-background.tsx",
45
+ "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"
46
+ },
47
+ {
48
+ "path": "src/visual-system/scene-templates/media-source.ts",
49
+ "type": "registry:component",
50
+ "target": "vanillasky/scene-templates/media-source.ts",
51
+ "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"
32
52
  }
33
53
  ],
34
54
  "meta": {
@@ -36,7 +56,7 @@
36
56
  "layer": "template",
37
57
  "tier": "free",
38
58
  "label": "Key figure",
39
- "description": "One supplied figure with one short label on black.",
59
+ "description": "One supplied figure with one short label over relevant media or black.",
40
60
  "usesGlobalTextEffect": false,
41
61
  "usesGlobalTransition": false,
42
62
  "usesGlobalBackgroundEffect": false,
@@ -81,6 +101,50 @@
81
101
  "Illustrative example"
82
102
  ],
83
103
  "description": "Exactly one short label identifying the quantity and its context."
104
+ },
105
+ "mediaKeyword": {
106
+ "type": "string",
107
+ "format": "stock-media-keyword",
108
+ "minLength": 1,
109
+ "maxLength": 80,
110
+ "description": "2–8 word literal subject/action search intent, maximum 80 characters. Host resolves URLs.",
111
+ "examples": [
112
+ "Ocean waves breaking on a rocky shore"
113
+ ]
114
+ },
115
+ "mediaUrl": {
116
+ "type": "string",
117
+ "format": "uri",
118
+ "description": "Host-only approved photo or video URL.",
119
+ "default": ""
120
+ },
121
+ "mediaType": {
122
+ "type": "string",
123
+ "enum": [
124
+ "photo",
125
+ "video"
126
+ ],
127
+ "default": "video",
128
+ "description": "Host-resolved asset kind."
129
+ },
130
+ "mediaPoster": {
131
+ "type": "string",
132
+ "format": "uri",
133
+ "description": "Host-only approved poster URL for video decoding.",
134
+ "default": ""
135
+ },
136
+ "shotDirection": {
137
+ "type": "string",
138
+ "maxLength": 220,
139
+ "description": "Optional action, framing and continuity for generation. Preserve the subject; do not request rendered text."
140
+ },
141
+ "mediaSource": {
142
+ "type": "string",
143
+ "enum": [
144
+ "generate",
145
+ "stock"
146
+ ],
147
+ "description": "Generate distinctive illustrative shots; use approved stock for familiar observable subjects."
84
148
  }
85
149
  },
86
150
  "required": [
@@ -89,7 +153,8 @@
89
153
  ],
90
154
  "additionalProperties": false,
91
155
  "x-vanillasky": {
92
- "requiresStat": true
156
+ "requiresStat": true,
157
+ "allowsStockMedia": true
93
158
  }
94
159
  }
95
160
  }