@vanillaskyai/video 0.10.22 → 0.11.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 +74 -1
- package/PUBLIC-API.md +45 -438
- package/README.md +57 -96
- package/dist/builtin-metadata-OT6V7TB4.js +8 -0
- package/dist/{chapter-title-2JVDU62E.js → chapter-title-2RCXX7SN.js} +1 -2
- package/dist/{chunk-PDQFQIQW.js → chunk-3EQ6PVWL.js} +1 -1
- package/dist/chunk-4G4JBMCM.js +37 -0
- package/dist/{chunk-7AA2JWHZ.js → chunk-5DOQTIMD.js} +6 -6
- package/dist/chunk-666HGTVZ.js +41 -0
- package/dist/{chunk-7M56IUUX.js → chunk-6Z3ID54H.js} +0 -18
- package/dist/{chunk-5BSJLT6H.js → chunk-E7FHBLAC.js} +16 -29
- package/dist/chunk-HIQM4J3U.js +30 -0
- package/dist/chunk-ISWQ6T5X.js +36 -0
- package/dist/{chunk-224QNWRA.js → chunk-K5J7ESRO.js} +1 -10
- package/dist/chunk-KMVRBUL5.js +50 -0
- package/dist/chunk-NM4CXXZY.js +25 -0
- package/dist/{chunk-44WND2VP.js → chunk-V2CP7PVY.js} +19 -63
- package/dist/{chunk-3O7OMMMF.js → chunk-WZESNEPT.js} +0 -1
- package/dist/{chunk-RXTN2CW6.js → chunk-Z3DLSLAJ.js} +1 -1
- package/dist/cinema-media-YZ2UANJG.js +365 -0
- package/dist/cli.js +154 -1753
- package/dist/{compose-video-BH5K6XWT.js → compose-video-H2YUQDUY.js} +4 -4
- package/dist/{events-B4YCc4vc.d.ts → events-BZAfl0Dm.d.ts} +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -6
- package/dist/preload-media-23JXUFCF.js +77 -0
- package/dist/react.d.ts +39 -15
- package/dist/react.js +1350 -739
- package/dist/scene-validation-SGLLLFY5.js +9 -0
- package/dist/server.d.ts +67 -117
- package/dist/server.js +262 -614
- package/dist/test.d.ts +2 -2
- package/dist/test.js +21 -21
- package/dist/{text-stream-LD364KBI.js → text-stream-SOVLYR2L.js} +2 -2
- package/dist/{types-BqB8zC9u.d.ts → types-CG-kPI81.d.ts} +3 -1
- package/dist/{types-DdZw4GRQ.d.ts → types-ht-Zw3Wv.d.ts} +1 -1
- package/docs/agent-integration.md +19 -64
- package/docs/architecture.md +82 -87
- package/docs/customization.md +49 -66
- package/docs/development.md +18 -17
- package/docs/errors.md +4 -4
- package/docs/getting-started.md +74 -93
- package/docs/media-and-audio.md +9 -7
- package/docs/performance.md +11 -6
- package/docs/persistence.md +3 -7
- package/docs/production.md +4 -15
- package/docs/prompt-and-input.md +1 -2
- package/docs/provider-integration.md +134 -181
- package/docs/reference/protocol.md +5 -14
- package/docs/reference/provider-adapters.md +21 -13
- package/docs/security.md +1 -14
- package/docs/testing.md +56 -123
- package/package.json +5 -27
- package/starters/video-chat/.env.example +12 -4
- package/starters/video-chat/.env.native.example +13 -0
- package/starters/video-chat/README.md +72 -11
- package/starters/video-chat/package.json +1 -1
- package/starters/video-chat/providers/text-native.ts +94 -0
- package/starters/video-chat/providers/text.ts +19 -0
- package/starters/video-chat/providers/transcription.ts +32 -0
- package/starters/video-chat/providers/video-custom.ts +45 -0
- package/starters/video-chat/providers/video-delivery.ts +57 -0
- package/starters/video-chat/providers/video-google.ts +48 -0
- package/starters/video-chat/providers/video-job.ts +103 -0
- package/starters/video-chat/providers/video-runway.ts +45 -0
- package/starters/video-chat/providers/video.ts +39 -63
- package/starters/video-chat/server.ts +3 -29
- package/starters/video-chat/vite.config.ts +2 -8
- package/styles/video-chat.css +8 -105
- package/dist/builtin-server-W4PLJUZ5.js +0 -8
- package/dist/catalog-types-WTbLP6Jh.d.ts +0 -78
- package/dist/check-runtime.d.ts +0 -15
- package/dist/check-runtime.js +0 -96
- package/dist/chunk-2E6T633S.js +0 -27
- package/dist/chunk-4YM2M62S.js +0 -13
- package/dist/chunk-4ZJLPHBV.js +0 -684
- package/dist/chunk-5JBMYQP6.js +0 -156
- package/dist/chunk-73NTSFFI.js +0 -81
- package/dist/chunk-EGVQODKU.js +0 -83
- package/dist/chunk-HFVNAPHZ.js +0 -35
- package/dist/chunk-IIN5M5HW.js +0 -697
- package/dist/chunk-IQMYK5DX.js +0 -133
- package/dist/chunk-IR44XKBI.js +0 -46
- package/dist/chunk-JKVOBTRO.js +0 -145
- package/dist/chunk-LVM5Q2DL.js +0 -68
- package/dist/chunk-M4QTEJTK.js +0 -709
- package/dist/chunk-QSBDB4J2.js +0 -16
- package/dist/chunk-R3XAOMKP.js +0 -29
- package/dist/chunk-SPVTJH3F.js +0 -24
- package/dist/chunk-YAHT3LST.js +0 -663
- package/dist/chunk-ZD2VTUYR.js +0 -28
- package/dist/cinema-media-HYCDG65Z.js +0 -11
- package/dist/comparison-XXAP4S4J.js +0 -36
- package/dist/editorial-timeline-VUQC6KPA.js +0 -42
- package/dist/key-figure-4TJK7HLT.js +0 -29
- package/dist/kit-DrRpdn0p.d.ts +0 -79
- package/dist/mobile-message-FIHAO6XC.js +0 -49
- package/dist/preload-media-LJXWKTWG.js +0 -57
- package/dist/quote-GRPIYKQJ.js +0 -32
- package/dist/system-prompt-AG26KJAA.js +0 -12
- package/dist/template-catalog.d.ts +0 -400
- package/dist/template-catalog.js +0 -6
- package/dist/templates.d.ts +0 -25
- package/dist/templates.js +0 -102
- package/dist/validate-4OUD2CLX.js +0 -10
- package/docs/concepts.md +0 -107
- package/docs/custom-templates.md +0 -346
- package/docs/immersive-interface.md +0 -86
- package/docs/maintainers/cinematic-migration.md +0 -38
- package/docs/motion-and-effects.md +0 -109
- package/docs/reference/design-system.html +0 -125
- package/docs/responsive-orientation.md +0 -37
- package/docs/streaming-protocol.md +0 -15
- package/examples/custom-template/README.md +0 -19
- package/examples/custom-template/minimal-text.tsx +0 -82
- package/examples/custom-template/structured-data.tsx +0 -104
- package/registry/items/backgrounds.json +0 -70
- package/registry/items/chapterTitle.json +0 -80
- package/registry/items/cinemaMedia.json +0 -136
- package/registry/items/comparison.json +0 -168
- package/registry/items/editorialTimeline.json +0 -172
- package/registry/items/keyFigure.json +0 -162
- package/registry/items/mobileMessage.json +0 -153
- package/registry/items/motion.json +0 -45
- package/registry/items/quote.json +0 -161
- package/registry/items/template-context.json +0 -31
- package/registry/items/theme.json +0 -47
- package/registry/items/typography.json +0 -47
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
-
"name": "backgrounds",
|
|
4
|
-
"type": "registry:lib",
|
|
5
|
-
"title": "Backgrounds",
|
|
6
|
-
"description": "Paint deterministic brand backdrops and keep their visual layer moving.",
|
|
7
|
-
"dependencies": [
|
|
8
|
-
"react"
|
|
9
|
-
],
|
|
10
|
-
"registryDependencies": [
|
|
11
|
-
"@vanillasky/motion",
|
|
12
|
-
"@vanillasky/theme"
|
|
13
|
-
],
|
|
14
|
-
"files": [
|
|
15
|
-
{
|
|
16
|
-
"path": "src/visual-system/backgrounds/index.ts",
|
|
17
|
-
"type": "registry:lib",
|
|
18
|
-
"target": "vanillasky/backgrounds/index.ts",
|
|
19
|
-
"content": "/** Brand-safe scene backgrounds and continuous background motion. */\nexport {\n TOP_TEXT_AREA_RATIO,\n gradientBackground,\n BrandGradientOverlay,\n} from \"../scene-templates/color-utils\";\nexport * from \"../scene-templates/background-effect\";\n"
|
|
20
|
-
},
|
|
21
|
-
{
|
|
22
|
-
"path": "src/visual-system/scene-templates/background-effect.ts",
|
|
23
|
-
"type": "registry:lib",
|
|
24
|
-
"target": "vanillasky/scene-templates/background-effect.ts",
|
|
25
|
-
"content": "/**\n * Background motion effects for templates.\n *\n * Applied to the background layer (media, gradient) throughout the entire scene.\n * Continuous motion — not tied to text timing.\n *\n * Templates with usesGlobalBackgroundEffect: true consume the video-level default.\n *\n * 9 effects, each with a distinct mood:\n * - static: No motion — intentional stillness\n * - slow-zoom-in: Draw attention inward, build focus\n * - slow-zoom-out: Reveal, establish, pull back\n * - ken-burns: Classic cinematic photo motion (zoom + pan, direction alternates per scene)\n * - drift: Gentle lateral pan (direction alternates per scene)\n * - pulse: Beat-synced energy\n * - breathe: Ambient dreamy float\n * - slow-tilt: Tension, Dutch angle drift\n * - camera-shake: Intensity, handheld drama\n */\n\nimport { interpolate } from \"../motion\";\n\nexport interface BackgroundTransform {\n transform: string;\n transformOrigin: string;\n}\n\nexport const BACKGROUND_EFFECTS = [\n \"static\",\n \"slow-zoom-in\",\n \"slow-zoom-out\",\n \"ken-burns\",\n \"drift\",\n \"pulse\",\n \"breathe\",\n \"slow-tilt\",\n \"camera-shake\",\n] as const;\n\n// ─── Directional variant helpers ────────────────────────────────\n// Ken Burns and Drift have internal direction variants that alternate\n// by scene index to create visual variety across a video.\n\ntype Direction = \"right\" | \"left\" | \"up\" | \"down\";\nconst DIRECTIONS: Direction[] = [\"right\", \"left\", \"up\", \"down\"];\n\nfunction getKenBurnsTransform(progress: number, direction: Direction): BackgroundTransform {\n const scale = interpolate(progress, [0, 1], [1.05, 1.15]);\n const mainAxis = direction === \"right\" || direction === \"left\";\n const sign = direction === \"right\" || direction === \"down\" ? 1 : -1;\n const primary = interpolate(progress, [0, 1], [-2 * sign, 2 * sign]);\n const secondary = interpolate(progress, [0, 1], [-0.5, 0.5]);\n\n const x = mainAxis ? primary : secondary;\n const y = mainAxis ? secondary : primary;\n\n return {\n transform: `scale(${scale}) translate(${x}%, ${y}%)`,\n transformOrigin: \"center\",\n };\n}\n\nfunction getDriftTransform(progress: number, direction: Direction): BackgroundTransform {\n const horizontal = direction === \"right\" || direction === \"left\";\n const sign = direction === \"right\" || direction === \"down\" ? 1 : -1;\n const offset = interpolate(progress, [0, 1], [-2 * sign, 2 * sign]);\n\n return {\n transform: horizontal\n ? `scale(1.1) translateX(${offset}%)`\n : `scale(1.1) translateY(${offset}%)`,\n transformOrigin: \"center\",\n };\n}\n\n// ─── Public API ─────────────────────────────────────────────────\n\n/**\n * Get background transform for the current progress.\n *\n * @param effect — background effect name\n * @param progress — scene progress 0→1\n * @param beatIntensity — 0→1 for beat-reactive effects\n * @param sceneIndex — used to alternate direction for ken-burns/drift\n */\nexport function getBackgroundTransform(\n effect: string | undefined,\n progress: number,\n beatIntensity = 0,\n sceneIndex = 0,\n): BackgroundTransform {\n const dir = DIRECTIONS[sceneIndex % DIRECTIONS.length];\n\n // Camera motion is opt-in. In particular, a scene-relative zoom restarts\n // at every cut and can look like the incoming media snaps backwards.\n // Footage already contains motion, while photo scenes that benefit from a\n // Ken Burns treatment can request one explicitly.\n const resolved = effect ?? \"static\";\n\n switch (resolved) {\n case \"slow-zoom-in\":\n return {\n transform: `scale(${interpolate(progress, [0, 1], [1, 1.12])})`,\n transformOrigin: \"center\",\n };\n\n case \"slow-zoom-out\":\n return {\n transform: `scale(${interpolate(progress, [0, 1], [1.12, 1])})`,\n transformOrigin: \"center\",\n };\n\n case \"ken-burns\":\n return getKenBurnsTransform(progress, dir);\n\n case \"drift\":\n return getDriftTransform(progress, dir);\n\n case \"pulse\": {\n // Subtle scale pulse synced to beat\n const base = 1 + beatIntensity * 0.04;\n const breathe = 1 + Math.sin(progress * Math.PI * 2) * 0.02;\n return {\n transform: `scale(${base * breathe})`,\n transformOrigin: \"center\",\n };\n }\n\n case \"breathe\": {\n // Slow ambient float — gentle scale + vertical drift, not beat-synced\n const s = 1 + Math.sin(progress * Math.PI) * 0.03;\n const y = Math.sin(progress * Math.PI) * 0.5;\n return {\n transform: `scale(${s}) translateY(${y}%)`,\n transformOrigin: \"center\",\n };\n }\n\n case \"slow-tilt\": {\n // Subtle Dutch angle drift\n const angle = interpolate(progress, [0, 1], [0, 2.5]);\n return {\n transform: `scale(1.08) rotate(${angle}deg)`,\n transformOrigin: \"center\",\n };\n }\n\n case \"camera-shake\": {\n // Rapid small offsets for handheld/dramatic feel\n // Deterministic noise based on progress for reproducibility\n const t = progress * 40;\n const sx = Math.sin(t * 2.3) * 0.4 + Math.sin(t * 5.7) * 0.2;\n const sy = Math.cos(t * 3.1) * 0.3 + Math.cos(t * 4.9) * 0.15;\n const sr = Math.sin(t * 1.7) * 0.3;\n return {\n transform: `scale(1.05) translate(${sx}%, ${sy}%) rotate(${sr}deg)`,\n transformOrigin: \"center\",\n };\n }\n\n case \"static\":\n default:\n return {\n transform: \"none\",\n transformOrigin: \"center\",\n };\n }\n}\n"
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
"path": "src/visual-system/scene-templates/external-video-backdrop.tsx",
|
|
29
|
-
"type": "registry:lib",
|
|
30
|
-
"target": "vanillasky/scene-templates/external-video-backdrop.tsx",
|
|
31
|
-
"content": "import React from \"react\";\n\n// Source-owned templates may live in a consumer's tree while VideoFrame comes\n// from the package. Both copies must observe the same internal context or the\n// consumer template would mount a second video over the player-owned plane,\n// and would never inherit the player's native media audio state.\nexport type ExternalVideoBackdropMode = false | \"pending\" | \"ready\" | \"fallback\";\n\nexport type MediaRecoveryReason = \"decode-error\" | \"frame-readiness-timeout\" | \"stalled-media\" | \"playback-error\";\n\ninterface BackdropContextValue {\n mode: ExternalVideoBackdropMode;\n audioMuted: boolean;\n audioVolume: number;\n preparingNarration?: boolean;\n onMediaError?: (reason?: MediaRecoveryReason) => void;\n}\n\nconst DEFAULT: BackdropContextValue = { mode: false, audioMuted: true, audioVolume: 1 };\n\nconst sharedContext = globalThis as typeof globalThis & {\n __vanillaskyVideoBackdropContext?: React.Context<BackdropContextValue>;\n};\nconst BackdropContext = sharedContext.__vanillaskyVideoBackdropContext\n ??= React.createContext<BackdropContextValue>(DEFAULT);\n\nexport function ExternalVideoBackdropProvider({\n mode,\n audioMuted = true,\n audioVolume = 1,\n preparingNarration = false,\n onMediaError,\n children,\n}: {\n mode: ExternalVideoBackdropMode;\n audioMuted?: boolean;\n audioVolume?: number;\n preparingNarration?: boolean;\n onMediaError?: (reason?: MediaRecoveryReason) => void;\n children: React.ReactNode;\n}) {\n const value = React.useMemo(\n () => ({ mode, audioMuted, audioVolume, preparingNarration, onMediaError }),\n [mode, audioMuted, audioVolume, preparingNarration, onMediaError],\n );\n return (\n <BackdropContext.Provider value={value}>\n {children}\n </BackdropContext.Provider>\n );\n}\n\nexport function useExternalVideoBackdrop(): ExternalVideoBackdropMode {\n return React.useContext(BackdropContext).mode;\n}\n\nexport function useMediaAudio(): { muted: boolean; volume: number } {\n const { audioMuted, audioVolume } = React.useContext(BackdropContext);\n return { muted: audioMuted, volume: audioVolume };\n}\n\n/** Internal first-frame priming state; an explicit viewer pause never sets it. */\nexport function useNarrationPreroll(): boolean {\n return React.useContext(BackdropContext).preparingNarration === true;\n}\n\n/** Routes local decoder/playback failures to the scene-owned recovery surface. */\nexport function useMediaFailure(): ((reason?: MediaRecoveryReason) => void) | undefined {\n return React.useContext(BackdropContext).onMediaError;\n}\n"
|
|
32
|
-
},
|
|
33
|
-
{
|
|
34
|
-
"path": "src/visual-system/scene-templates/media-position.ts",
|
|
35
|
-
"type": "registry:lib",
|
|
36
|
-
"target": "vanillasky/scene-templates/media-position.ts",
|
|
37
|
-
"content": "export type MediaPosition = \"center\" | \"top\" | \"bottom\" | \"left\" | \"right\";\n\nconst MEDIA_POSITIONS: Record<MediaPosition, string> = {\n center: \"center center\",\n top: \"center top\",\n bottom: \"center bottom\",\n left: \"left center\",\n right: \"right center\",\n};\n\nexport function resolveMediaPosition(value: string): string {\n return MEDIA_POSITIONS[value as MediaPosition] ?? MEDIA_POSITIONS.center;\n}\n"
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
"path": "src/visual-system/scene-templates/scene-video-backdrop.tsx",
|
|
41
|
-
"type": "registry:lib",
|
|
42
|
-
"target": "vanillasky/scene-templates/scene-video-backdrop.tsx",
|
|
43
|
-
"content": "import React, { useCallback, useEffect, useRef, useState } from \"react\";\nimport { getBackgroundTransform } from \"../backgrounds\";\nimport { type MediaRecoveryReason, useMediaAudio, useMediaFailure, useNarrationPreroll } from \"./external-video-backdrop\";\nimport { resolveMediaPosition } from \"./media-position\";\n\nexport interface SceneVideoBackdropProps {\n mediaUrl: string;\n mediaPoster?: string;\n mediaPosition?: string;\n backgroundEffect?: string;\n progress: number;\n /** Narration-led visible duration; muted or pitch-preserving footage may be gently retimed. */\n sceneDuration?: number;\n /** Internal player-owned decoder priming, distinct from viewer pause. */\n preparingNarration?: boolean;\n beatIntensity?: number;\n isPlaying: boolean;\n muted?: boolean;\n volume?: number;\n playbackId?: string;\n onReady?: () => void;\n onError?: () => void;\n}\n\nexport const SceneVideoBackdrop: React.FC<SceneVideoBackdropProps> = ({\n mediaUrl,\n mediaPoster,\n mediaPosition = \"center\",\n backgroundEffect,\n progress,\n sceneDuration,\n preparingNarration = false,\n beatIntensity = 0,\n isPlaying,\n muted,\n volume,\n playbackId = mediaUrl,\n onReady,\n onError,\n}) => {\n const inheritedAudio = useMediaAudio();\n const reportMediaFailure = useMediaFailure();\n const inheritedPreroll = useNarrationPreroll();\n const rewindPreroll = preparingNarration || inheritedPreroll;\n const resolvedMuted = muted ?? inheritedAudio.muted;\n const resolvedVolume = volume ?? inheritedAudio.volume;\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const bgTransform = getBackgroundTransform(backgroundEffect, progress, beatIntensity);\n const [decodedVideoUrl, setDecodedVideoUrl] = useState<string>();\n const [waitingKey, setWaitingKey] = useState<string>();\n const [exhaustedKey, setExhaustedKey] = useState<string>();\n\n const videoRef = useRef<HTMLVideoElement>(null);\n const playableVideoUrl = useRef<string | undefined>(undefined);\n const startedVideoUrl = useRef<string | undefined>(undefined);\n const startedPlaybackId = useRef<string | undefined>(undefined);\n const videoPresentationKey = `${playbackId}\\0${mediaUrl}`;\n\n const presentationRef = useRef({ key: videoPresentationKey, playing: isPlaying });\n presentationRef.current = { key: videoPresentationKey, playing: isPlaying };\n const unavailable = (reason: MediaRecoveryReason = \"playback-error\") => {\n if (presentationRef.current.key === videoPresentationKey && presentationRef.current.playing) {\n setExhaustedKey(videoPresentationKey);\n onError?.();\n reportMediaFailure?.(reason);\n }\n };\n useEffect(() => {\n // Hidden preparation is bounded by mounted readiness once its cut is due.\n // It must not spend the next scene's stall deadline while still incoming.\n if (!isPlaying || rewindPreroll || waitingKey !== videoPresentationKey) {\n if (waitingKey) setWaitingKey(undefined);\n return;\n }\n const video = videoRef.current;\n if (!video) return;\n const expectedSource = video.getAttribute(\"src\") === mediaUrl ? video.src : undefined;\n let awaitingPlayback = playableVideoUrl.current !== mediaUrl || video.currentSrc !== expectedSource;\n let previousTime = video.currentTime;\n let forwardFrames = 0;\n let stopped = false;\n let frame: number | undefined;\n let poll: ReturnType<typeof setTimeout> | undefined;\n const observe = (_now?: number, metadata?: VideoFrameCallbackMetadata) => {\n if (stopped) return;\n const currentSource = video.currentSrc === expectedSource;\n if (currentSource && awaitingPlayback && video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {\n playableVideoUrl.current = mediaUrl;\n awaitingPlayback = false;\n clearTimeout(deadline);\n deadline = setTimeout(fail, 1000);\n }\n const time = metadata?.mediaTime ?? video.currentTime;\n if (!currentSource || video.seeking || time < previousTime) forwardFrames = 0;\n else if (time > previousTime + .001) forwardFrames++;\n previousTime = time;\n // One seek frame is not resumed motion. Require consecutive forward\n // observations before releasing the original bounded stall deadline.\n if (forwardFrames >= 2) {\n playableVideoUrl.current = mediaUrl;\n stopped = true;\n clearTimeout(deadline);\n setWaitingKey(undefined);\n return;\n }\n if (video.requestVideoFrameCallback) frame = video.requestVideoFrameCallback(observe);\n else poll = setTimeout(observe, 50);\n };\n // A seek can emit waiting without another playing event, even while frames\n // resume. Keep the decoder visible and observe motion directly. A real\n // stall gets the player's authored chapter instead of an endless spinner.\n const fail = () => { if (!stopped) { stopped = true; unavailable(awaitingPlayback ? \"frame-readiness-timeout\" : \"stalled-media\"); } };\n // Initial network/decode work has the same bound as mounted readiness.\n // A decoded still with no future data is still cold, even after its first\n // frame callback. Keep the short bound only after playback was available.\n let deadline = setTimeout(fail, awaitingPlayback ? 8000 : 1000);\n observe();\n return () => {\n stopped = true;\n clearTimeout(deadline);\n clearTimeout(poll);\n if (frame !== undefined) video.cancelVideoFrameCallback?.(frame);\n };\n }, [waitingKey, videoPresentationKey, isPlaying, rewindPreroll]);\n\n const onReadyRef = useRef(onReady);\n onReadyRef.current = onReady;\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n let stopped = false;\n let frame: number | undefined;\n const markPresented = () => {\n if (stopped || !video.isConnected || presentationRef.current.key !== videoPresentationKey\n || video.getAttribute(\"src\") !== mediaUrl || video.currentSrc !== video.src) return false;\n if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) playableVideoUrl.current = mediaUrl;\n video.dispatchEvent(new Event(\"vanillasky:video-frame-presented\", { bubbles: true }));\n onReadyRef.current?.();\n setDecodedVideoUrl(mediaUrl);\n stopped = true;\n return true;\n };\n const observe = () => {\n if (stopped || frame !== undefined) return;\n if (video.requestVideoFrameCallback) {\n frame = video.requestVideoFrameCallback(() => {\n frame = undefined;\n if (!markPresented()) observe();\n });\n } else if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) markPresented();\n };\n // A cached resource can finish loading while its Suspense tree is still\n // detached. Observe the mounted frame even if loadeddata was missed.\n video.addEventListener(\"loadeddata\", observe);\n observe();\n return () => {\n stopped = true;\n video.removeEventListener(\"loadeddata\", observe);\n if (frame !== undefined) video.cancelVideoFrameCallback?.(frame);\n };\n }, [mediaUrl, videoPresentationKey]);\n\n const fitDuration = useCallback((video: HTMLVideoElement) => {\n // Allow a small decode-to-speech onset margin without changing narration.\n video.playbackRate = (resolvedMuted || video.preservesPitch === true) && sceneDuration && Number.isFinite(video.duration) && video.duration > 0\n ? Math.max(.75, Math.min(1, video.duration / (sceneDuration + .2))) : 1;\n }, [resolvedMuted, sceneDuration]);\n useEffect(() => {\n if (videoRef.current) fitDuration(videoRef.current);\n }, [fitDuration]);\n const continueMotion = (video: HTMLVideoElement) => {\n if (!isPlaying) return;\n // The finite scene clock bounds silent coverage. Speech may outlast a\n // short clip; repeat motion until the scene ends, never audible dialogue.\n if (!resolvedMuted) {\n unavailable();\n return;\n }\n video.currentTime = 0;\n void video.play().catch(() => unavailable());\n };\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n // React Strict Mode rehearses setup → cleanup → setup in development.\n // The cleanup deliberately releases the decoder, so the repeated setup\n // must restore the declarative source before the playback effect runs.\n if (video.getAttribute(\"src\") !== mediaUrl) {\n video.setAttribute(\"src\", mediaUrl);\n video.load();\n }\n }, [mediaUrl]);\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n // A source change already starts a native load via React's src update.\n // Tear down the decoder only on unmount, never cancel that new request.\n return () => {\n video.pause();\n video.removeAttribute(\"src\");\n video.load();\n startedVideoUrl.current = undefined;\n startedPlaybackId.current = undefined;\n };\n }, []);\n\n useEffect(() => {\n const video = videoRef.current;\n if (video) video.volume = resolvedVolume;\n }, [resolvedVolume]);\n\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n if (!isPlaying) {\n video.pause();\n // Keep silent prepared data intact through narration startup. Seeking\n // back a few milliseconds can trigger another cold Range request.\n if (rewindPreroll && !resolvedMuted && video.currentTime > 0) video.currentTime = 0;\n return;\n }\n if (startedPlaybackId.current === playbackId) {\n if (video.ended) continueMotion(video);\n else void video.play().catch(() => unavailable());\n return;\n }\n const changingSource = startedVideoUrl.current !== undefined && startedVideoUrl.current !== mediaUrl;\n fitDuration(video);\n if (!changingSource && video.currentTime > 0) video.currentTime = 0;\n video.play().catch(() => unavailable());\n startedVideoUrl.current = mediaUrl;\n startedPlaybackId.current = playbackId;\n }, [isPlaying, mediaUrl, playbackId, rewindPreroll]);\n\n const enforceRequestedPause = (event: React.SyntheticEvent<HTMLVideoElement>) => {\n // WebKit may enter playback without a playing event while seeking or\n // waiting. Both native start events must honor the latest requested hold.\n if (presentationRef.current.playing) return;\n event.currentTarget.pause();\n if (rewindPreroll && !resolvedMuted && event.currentTarget.currentTime > 0) event.currentTarget.currentTime = 0;\n };\n\n const mediaStyle: React.CSSProperties = {\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: resolvedPosition,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n };\n return (\n <>\n {exhaustedKey === videoPresentationKey && <div\n role=\"status\" data-media-continuity=\"exhausted\"\n style={{ position: \"absolute\", inset: 0, zIndex: 3, background: \"#000\", color: \"#bbb\", display: \"grid\", placeContent: \"center\", font: \"14px system-ui\" }}\n >Visual unavailable</div>}\n <video\n ref={videoRef}\n src={mediaUrl}\n poster={decodedVideoUrl !== mediaUrl ? mediaPoster || undefined : undefined}\n muted={resolvedMuted}\n // Let the decoder repeat without an ended → script seek/play round trip.\n // The finite scene clock still owns pause and disposal; never loop speech.\n loop={resolvedMuted && isPlaying && Number.isFinite(sceneDuration) && Number(sceneDuration) > 0}\n playsInline\n preload=\"auto\"\n onLoadedMetadata={event => fitDuration(event.currentTarget)}\n onEnded={event => continueMotion(event.currentTarget)}\n onPlay={enforceRequestedPause}\n onPlaying={event => {\n if (event.currentTarget.currentSrc === event.currentTarget.src\n && event.currentTarget.getAttribute(\"src\") === mediaUrl\n && event.currentTarget.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) playableVideoUrl.current = mediaUrl;\n enforceRequestedPause(event);\n }}\n onWaiting={() => { if (isPlaying) setWaitingKey(videoPresentationKey); }}\n onError={onError}\n data-media-position={mediaPosition}\n data-video-backdrop=\"scene\"\n style={{ ...mediaStyle, visibility: exhaustedKey === videoPresentationKey ? \"hidden\" : undefined }}\n />\n </>\n );\n};\n"
|
|
44
|
-
},
|
|
45
|
-
{
|
|
46
|
-
"path": "src/visual-system/scene-templates/color-utils.ts",
|
|
47
|
-
"type": "registry:lib",
|
|
48
|
-
"target": "vanillasky/scene-templates/color-utils.ts",
|
|
49
|
-
"content": "/**\n * Color utilities for scene templates.\n *\n * Provides safe color manipulation that works with hex colors\n * and graceful fallbacks for non-hex inputs.\n */\nimport React from \"react\";\nimport { resolveTokens, darken, orderDarkToLight, type BackgroundFamily } from \"../theme\";\n\n// ─── Layout constants ───────────────────────────────────────\n//\n// Shared vertical layout for templates that place a TextOverlay at the top of\n// the scene (showcase-*, chart-*, infographic-*). Using a single constant\n// guarantees the headline sits in the same place across every template, so\n// scene-to-scene cuts don't visually jump.\n//\n// TextOverlay vertically centers itself inside its container, so the visual\n// midline of the headline = TOP_TEXT_AREA_RATIO / 2 of the scene height.\nexport const TOP_TEXT_AREA_RATIO = 0.32;\n\n/**\n * Gradient background — clean 2-color linear gradient with a subtle\n * animated breathing overlay. Matches the look of a plain CSS\n * `linear-gradient(90deg, colorA, colorB)` so palette chips preview\n * exactly what templates render.\n *\n * The animation is deliberately minimal: two low-opacity radial blobs\n * of the same two colors drift slowly, adding just enough motion for\n * video without introducing a third \"midtone\" color that muddies\n * the gradient (a common source of unwanted purple/brown tints).\n */\nexport function gradientBackground({\n colorA,\n colorB,\n solidBg,\n progress,\n sceneDuration,\n seed,\n family,\n}: {\n colorA: string;\n colorB: string;\n /**\n * When set, the function returns this color as a solid background\n * (no gradient, no animated radial overlays). Used when the user has\n * a scraped brand whose darkest color reads as a real surface — the\n * accent stays on text/CTAs, but the bg goes flat instead of pairing\n * with an unrelated brand color and producing a noisy gradient.\n */\n solidBg?: string;\n progress: number;\n sceneDuration?: number;\n seed: number;\n /**\n * Background family from the style preset. Omitted = \"mesh\", the original\n * look — so existing configs are untouched. All families are pure CSS\n * background strings (no `filter`, which the SVG export can't rasterize)\n * and stay deterministic in `progress`.\n */\n family?: BackgroundFamily;\n}): string {\n if (solidBg) return solidBg;\n // Every scene owns its foreground motion clock, but consecutive scenes that\n // use the same brand backdrop must not visibly reset that backdrop. Run one\n // eased closed loop per scene: the gradient has zero velocity and the exact\n // same pixels at progress 0 and 1, regardless of scene duration or content\n // seed. Seeded variation is strongest mid-scene and converges to zero at\n // both handoff edges.\n const normalizedProgress = Math.max(0, Math.min(1, progress));\n const atBoundary = normalizedProgress === 0 || normalizedProgress === 1;\n const easedLoop = normalizedProgress * normalizedProgress * (3 - 2 * normalizedProgress);\n const seedEnvelope = atBoundary ? 0 : Math.sin(normalizedProgress * Math.PI) ** 2;\n void sceneDuration;\n\n const seededRandom = (s: number): number => {\n const x = Math.sin(s * 12.9898 + s * 78.233) * 43758.5453;\n return x - Math.floor(x);\n };\n const phase = seededRandom(seed) * Math.PI * 2 * seedEnvelope;\n const t = (atBoundary ? 0 : easedLoop * Math.PI * 2) + phase;\n\n // Darker of the two colors anchors the top of the frame so text and logos\n // sit over the stronger value. 180deg = top→bottom.\n const [topColor, bottomColor] = orderDarkToLight(colorA, colorB);\n\n if (family === \"wash\") {\n // No radials: one calm vertical ramp that drifts a few percent over the\n // scene. Reads considered rather than energetic.\n const shift = Math.sin(t) * 6;\n return `linear-gradient(${175 + shift}deg, ${topColor} 0%, ${bottomColor} 100%)`;\n }\n\n if (family === \"spotlight\") {\n // A single hard pool of accent high in the frame over a near-black\n // surround — high contrast, very little colour area.\n const x = 50 + Math.sin(t) * 8;\n const y = 28 + Math.cos(t + 1) * 5;\n return `\n radial-gradient(ellipse 70% 55% at ${x}% ${y}%, ${colorA}66 0%, ${colorA}1a 45%, transparent 72%),\n linear-gradient(180deg, ${darken(topColor, 0.55)} 0%, ${darken(bottomColor, 0.7)} 100%)\n `;\n }\n\n const x1 = 25 + Math.sin(t) * 25;\n const y1 = 30 + Math.cos(t + 1.0) * 25;\n const x2 = 75 + Math.sin(t + 2.0) * 25;\n const y2 = 70 + Math.cos(t + 3.5) * 25;\n\n return `\n radial-gradient(ellipse 60% 60% at ${x1}% ${y1}%, ${colorA}4d 0%, transparent 65%),\n radial-gradient(ellipse 60% 60% at ${x2}% ${y2}%, ${colorB}4d 0%, transparent 65%),\n linear-gradient(180deg, ${topColor} 0%, ${bottomColor} 100%)\n `;\n}\n\n/**\n * Animated brand-background overlay. Foreground semantic colours never alter\n * this visual background.\n */\nexport const BrandGradientOverlay: React.FC<{\n style: Parameters<typeof resolveTokens>[0];\n progress: number;\n sceneDuration?: number;\n seed: number;\n}> = ({ style: globalStyle, progress, sceneDuration, seed }) => {\n const tokens = resolveTokens(globalStyle);\n const [first, second] = tokens.background.type === \"gradient\"\n ? tokens.background.colors\n : [tokens.background.color, tokens.background.color];\n const solidBg = tokens.background.type === \"solid\" ? tokens.background.color : undefined;\n\n return React.createElement(\"div\", {\n \"data-brand-gradient\": \"true\",\n style: {\n position: \"absolute\" as const,\n inset: 0,\n background: gradientBackground({ colorA: first, colorB: second, solidBg, progress, sceneDuration, seed, family: tokens.preset.background }),\n pointerEvents: \"none\" as const,\n },\n });\n};\n"
|
|
50
|
-
}
|
|
51
|
-
],
|
|
52
|
-
"meta": {
|
|
53
|
-
"vanillasky": {
|
|
54
|
-
"layer": "lib",
|
|
55
|
-
"tier": "free",
|
|
56
|
-
"domain": "appearance",
|
|
57
|
-
"level": "composed",
|
|
58
|
-
"audiences": [
|
|
59
|
-
"custom-scenes",
|
|
60
|
-
"react-developers"
|
|
61
|
-
],
|
|
62
|
-
"dependencies": [
|
|
63
|
-
"theme",
|
|
64
|
-
"motion"
|
|
65
|
-
],
|
|
66
|
-
"useWhen": "Use gradientBackground to paint the scene layer and getBackgroundTransform to add deterministic camera movement across the full scene duration.",
|
|
67
|
-
"avoidWhen": "Do not apply these transforms to text or interactive chrome, and do not use CSS filter effects in the export path."
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
-
"name": "chapterTitle",
|
|
4
|
-
"type": "registry:block",
|
|
5
|
-
"title": "Chapter",
|
|
6
|
-
"description": "Centered white typography fades in, holds, and fades out 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/chapter-title.tsx",
|
|
17
|
-
"type": "registry:component",
|
|
18
|
-
"target": "vanillasky/scene-templates/chapter-title.tsx",
|
|
19
|
-
"content": "import type { SceneTemplateProps } from './types';\n\nimport {editorialFont,fade as ease} from './editorial-typography';\n\n/** A quiet chapter beat: fade in, read, fade to black. */\nfunction TitleScene({ variables, width, height, progress, motionProgress }: SceneTemplateProps) {\n const title = String(variables.title ?? 'A different perspective');\n const unit = Math.min(width, height);\n const presentation = motionProgress ?? progress;\n const opacity = ease(presentation / .22) * (1 - ease((presentation - .76) / .24));\n return <div data-template=\"title\" data-title-treatment=\"quiet-fade\" style={{position:'absolute',inset:0,background:'#000',color:'#fff',display:'flex',alignItems:'center',justifyContent:'center'}}>\n <div data-title-composition=\"centered\" style={{width:'76%',textAlign:'center',opacity,fontFamily:editorialFont,fontWeight:500,fontSize:unit * .068,lineHeight:1.18,letterSpacing:'-.025em',textWrap:'balance',overflowWrap:'anywhere'}}>{title}</div>\n </div>;\n}\n\nexport const TitleSceneTemplate = TitleScene;\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": "Chapter",
|
|
39
|
-
"description": "Centered white typography fades in, holds, and fades out on black.",
|
|
40
|
-
"usesGlobalTextEffect": false,
|
|
41
|
-
"usesGlobalTransition": false,
|
|
42
|
-
"usesGlobalBackgroundEffect": false,
|
|
43
|
-
"textCanvas": "tight",
|
|
44
|
-
"family": "Explainers",
|
|
45
|
-
"jobs": [
|
|
46
|
-
"setup",
|
|
47
|
-
"payoff"
|
|
48
|
-
],
|
|
49
|
-
"register": "motion-led",
|
|
50
|
-
"useWhen": "An opening, new chapter or deliberate short takeaway needs its own visual beat. ",
|
|
51
|
-
"avoidWhen": "A headline would merely repeat subtitles over an ordinary footage scene.",
|
|
52
|
-
"minDuration": 3,
|
|
53
|
-
"preferredDuration": 4,
|
|
54
|
-
"timing": {
|
|
55
|
-
"contentFields": [
|
|
56
|
-
"title"
|
|
57
|
-
],
|
|
58
|
-
"contentUnit": "words",
|
|
59
|
-
"revealSeconds": 0.8,
|
|
60
|
-
"holdSeconds": 1.5,
|
|
61
|
-
"exitSeconds": 0.8
|
|
62
|
-
},
|
|
63
|
-
"schema": {
|
|
64
|
-
"type": "object",
|
|
65
|
-
"properties": {
|
|
66
|
-
"title": {
|
|
67
|
-
"type": "string",
|
|
68
|
-
"maxLength": 65,
|
|
69
|
-
"description": "Short title. Line breaks optional.",
|
|
70
|
-
"default": "A different perspective"
|
|
71
|
-
}
|
|
72
|
-
},
|
|
73
|
-
"required": [
|
|
74
|
-
"title"
|
|
75
|
-
],
|
|
76
|
-
"additionalProperties": false
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
-
"name": "cinemaMedia",
|
|
4
|
-
"type": "registry:block",
|
|
5
|
-
"title": "Full-bleed",
|
|
6
|
-
"description": "Immersive photo or video without a headline.",
|
|
7
|
-
"dependencies": [
|
|
8
|
-
"react"
|
|
9
|
-
],
|
|
10
|
-
"registryDependencies": [
|
|
11
|
-
"@vanillasky/backgrounds",
|
|
12
|
-
"@vanillasky/template-context",
|
|
13
|
-
"@vanillasky/theme"
|
|
14
|
-
],
|
|
15
|
-
"files": [
|
|
16
|
-
{
|
|
17
|
-
"path": "src/visual-system/scene-templates/cinema-media.tsx",
|
|
18
|
-
"type": "registry:component",
|
|
19
|
-
"target": "vanillasky/scene-templates/cinema-media.tsx",
|
|
20
|
-
"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"
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
"path": "src/visual-system/scene-templates/types.ts",
|
|
24
|
-
"type": "registry:component",
|
|
25
|
-
"target": "vanillasky/scene-templates/types.ts",
|
|
26
|
-
"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"
|
|
27
|
-
},
|
|
28
|
-
{
|
|
29
|
-
"path": "src/visual-system/scene-templates/scene-background.tsx",
|
|
30
|
-
"type": "registry:component",
|
|
31
|
-
"target": "vanillasky/scene-templates/scene-background.tsx",
|
|
32
|
-
"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 sceneDuration={sceneDuration}\n beatIntensity={beatIntensity}\n isPlaying={isPlaying}\n onReady={() => setMediaPaint(\"ready\")}\n onError={() => setMediaPaint(\"failed\")}\n />\n ) : (\n <img\n src={mediaUrl}\n alt=\"\"\n aria-hidden=\"true\"\n draggable={false}\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: 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"
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
"path": "src/visual-system/scene-templates/media-source.ts",
|
|
36
|
-
"type": "registry:component",
|
|
37
|
-
"target": "vanillasky/scene-templates/media-source.ts",
|
|
38
|
-
"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"
|
|
39
|
-
}
|
|
40
|
-
],
|
|
41
|
-
"meta": {
|
|
42
|
-
"vanillasky": {
|
|
43
|
-
"layer": "template",
|
|
44
|
-
"tier": "free",
|
|
45
|
-
"label": "Full-bleed",
|
|
46
|
-
"description": "Immersive photo or video without a headline.",
|
|
47
|
-
"usesGlobalTextEffect": false,
|
|
48
|
-
"usesGlobalTransition": false,
|
|
49
|
-
"usesGlobalBackgroundEffect": false,
|
|
50
|
-
"textCanvas": "tight",
|
|
51
|
-
"family": "Media & motion",
|
|
52
|
-
"jobs": [
|
|
53
|
-
"setup",
|
|
54
|
-
"atmosphere",
|
|
55
|
-
"payoff"
|
|
56
|
-
],
|
|
57
|
-
"register": "motion-led",
|
|
58
|
-
"useWhen": "Show a relevant place, activity or subject. The asset is resolved separately from a shot description. ",
|
|
59
|
-
"avoidWhen": "No relevant asset can be resolved or the scene needs structured explanation.",
|
|
60
|
-
"minDuration": 3,
|
|
61
|
-
"preferredDuration": 6,
|
|
62
|
-
"timing": {
|
|
63
|
-
"contentFields": [],
|
|
64
|
-
"contentUnit": "words",
|
|
65
|
-
"revealSeconds": 0,
|
|
66
|
-
"holdSeconds": 3,
|
|
67
|
-
"exitSeconds": 0
|
|
68
|
-
},
|
|
69
|
-
"schema": {
|
|
70
|
-
"type": "object",
|
|
71
|
-
"properties": {
|
|
72
|
-
"mediaKeyword": {
|
|
73
|
-
"type": "string",
|
|
74
|
-
"format": "stock-media-keyword",
|
|
75
|
-
"minLength": 1,
|
|
76
|
-
"maxLength": 80,
|
|
77
|
-
"description": "2–8 word literal subject/action search intent, maximum 80 characters. Host resolves URLs.",
|
|
78
|
-
"examples": [
|
|
79
|
-
"Ocean waves breaking on a rocky shore"
|
|
80
|
-
]
|
|
81
|
-
},
|
|
82
|
-
"mediaUrl": {
|
|
83
|
-
"type": "string",
|
|
84
|
-
"format": "uri",
|
|
85
|
-
"description": "Host-only approved photo or video URL.",
|
|
86
|
-
"default": ""
|
|
87
|
-
},
|
|
88
|
-
"mediaType": {
|
|
89
|
-
"type": "string",
|
|
90
|
-
"enum": [
|
|
91
|
-
"photo",
|
|
92
|
-
"video"
|
|
93
|
-
],
|
|
94
|
-
"default": "video",
|
|
95
|
-
"description": "Host-resolved asset kind."
|
|
96
|
-
},
|
|
97
|
-
"mediaPoster": {
|
|
98
|
-
"type": "string",
|
|
99
|
-
"format": "uri",
|
|
100
|
-
"description": "Host-only approved poster URL for video decoding.",
|
|
101
|
-
"default": ""
|
|
102
|
-
},
|
|
103
|
-
"shotDirection": {
|
|
104
|
-
"type": "string",
|
|
105
|
-
"maxLength": 220,
|
|
106
|
-
"description": "Optional action, framing and continuity for generation. Preserve the subject; do not request rendered text."
|
|
107
|
-
},
|
|
108
|
-
"mediaSource": {
|
|
109
|
-
"type": "string",
|
|
110
|
-
"enum": [
|
|
111
|
-
"generate",
|
|
112
|
-
"stock"
|
|
113
|
-
],
|
|
114
|
-
"description": "Generate distinctive illustrative shots; use approved stock for familiar observable subjects."
|
|
115
|
-
},
|
|
116
|
-
"fallbackText": {
|
|
117
|
-
"type": "string",
|
|
118
|
-
"maxLength": 65,
|
|
119
|
-
"description": "Optional grounded concise chapter text if this shot cannot be resolved; not visible over footage."
|
|
120
|
-
}
|
|
121
|
-
},
|
|
122
|
-
"required": [],
|
|
123
|
-
"additionalProperties": false,
|
|
124
|
-
"x-vanillasky": {
|
|
125
|
-
"allowsStockMedia": true,
|
|
126
|
-
"requiredAnyOf": [
|
|
127
|
-
[
|
|
128
|
-
"mediaKeyword",
|
|
129
|
-
"mediaUrl"
|
|
130
|
-
]
|
|
131
|
-
]
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
-
"name": "comparison",
|
|
4
|
-
"type": "registry:block",
|
|
5
|
-
"title": "Comparison",
|
|
6
|
-
"description": "Two short statements with equal weight over relevant media or black, side by side or stacked in portrait.",
|
|
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/comparison.tsx",
|
|
19
|
-
"type": "registry:component",
|
|
20
|
-
"target": "vanillasky/scene-templates/comparison.tsx",
|
|
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"
|
|
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/editorial-typography.ts",
|
|
31
|
-
"type": "registry:component",
|
|
32
|
-
"target": "vanillasky/scene-templates/editorial-typography.ts",
|
|
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 sceneDuration={sceneDuration}\n beatIntensity={beatIntensity}\n isPlaying={isPlaying}\n onReady={() => setMediaPaint(\"ready\")}\n onError={() => setMediaPaint(\"failed\")}\n />\n ) : (\n <img\n src={mediaUrl}\n alt=\"\"\n aria-hidden=\"true\"\n draggable={false}\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: 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"
|
|
52
|
-
}
|
|
53
|
-
],
|
|
54
|
-
"meta": {
|
|
55
|
-
"vanillasky": {
|
|
56
|
-
"layer": "template",
|
|
57
|
-
"tier": "free",
|
|
58
|
-
"label": "Comparison",
|
|
59
|
-
"description": "Two short statements with equal weight over relevant media or black, side by side or stacked in portrait.",
|
|
60
|
-
"usesGlobalTextEffect": false,
|
|
61
|
-
"usesGlobalTransition": false,
|
|
62
|
-
"usesGlobalBackgroundEffect": false,
|
|
63
|
-
"textCanvas": "tight",
|
|
64
|
-
"family": "Explainers",
|
|
65
|
-
"jobs": [
|
|
66
|
-
"proof"
|
|
67
|
-
],
|
|
68
|
-
"register": "motion-led",
|
|
69
|
-
"useWhen": "Explain a before/after change or two alternatives with short parallel statements. ",
|
|
70
|
-
"avoidWhen": "There are more than two alternatives or a numerical chart is needed.",
|
|
71
|
-
"minDuration": 4,
|
|
72
|
-
"preferredDuration": 6,
|
|
73
|
-
"timing": {
|
|
74
|
-
"contentFields": [
|
|
75
|
-
"leftText",
|
|
76
|
-
"rightText",
|
|
77
|
-
"leftLabel",
|
|
78
|
-
"rightLabel"
|
|
79
|
-
],
|
|
80
|
-
"contentUnit": "words",
|
|
81
|
-
"revealSeconds": 1.2,
|
|
82
|
-
"holdSeconds": 2.5,
|
|
83
|
-
"exitSeconds": 0
|
|
84
|
-
},
|
|
85
|
-
"schema": {
|
|
86
|
-
"type": "object",
|
|
87
|
-
"properties": {
|
|
88
|
-
"leftLabel": {
|
|
89
|
-
"type": "string",
|
|
90
|
-
"maxLength": 20,
|
|
91
|
-
"description": "Optional short identifier for the first alternative."
|
|
92
|
-
},
|
|
93
|
-
"rightLabel": {
|
|
94
|
-
"type": "string",
|
|
95
|
-
"maxLength": 20,
|
|
96
|
-
"description": "Optional short identifier for the second alternative."
|
|
97
|
-
},
|
|
98
|
-
"leftText": {
|
|
99
|
-
"type": "string",
|
|
100
|
-
"minLength": 1,
|
|
101
|
-
"maxLength": 60,
|
|
102
|
-
"default": "More distractions",
|
|
103
|
-
"description": "First real alternative in one short phrase."
|
|
104
|
-
},
|
|
105
|
-
"rightText": {
|
|
106
|
-
"type": "string",
|
|
107
|
-
"minLength": 1,
|
|
108
|
-
"maxLength": 60,
|
|
109
|
-
"default": "More room to think",
|
|
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."
|
|
155
|
-
}
|
|
156
|
-
},
|
|
157
|
-
"required": [
|
|
158
|
-
"leftText",
|
|
159
|
-
"rightText"
|
|
160
|
-
],
|
|
161
|
-
"additionalProperties": false,
|
|
162
|
-
"x-vanillasky": {
|
|
163
|
-
"allowsStockMedia": true
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|