@vanillaskyai/video 0.5.6 → 0.5.7
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 +8 -0
- package/dist/{bg-confetti-572VDSYM.js → bg-confetti-SHQI7ATB.js} +2 -2
- package/dist/{bg-emoji-T4IEJ6U2.js → bg-emoji-XYFRA63Q.js} +2 -2
- package/dist/{bg-media-2D5SQSJX.js → bg-media-FEBATBT2.js} +8 -6
- package/dist/{brand-message-2JCTRIGS.js → brand-message-P56WEJPG.js} +5 -3
- package/dist/{chart-bar-GAJMUB2E.js → chart-bar-ZB45ZRKH.js} +5 -3
- package/dist/{chart-counter-HYGBGXNX.js → chart-counter-HFYNE4HT.js} +8 -6
- package/dist/{chart-progress-ring-DUUVMJAJ.js → chart-progress-ring-ZKMLRGSB.js} +5 -3
- package/dist/check-runtime.js +3 -1
- package/dist/{chunk-CTEYFVUY.js → chunk-6M6DATLW.js} +26 -82
- package/dist/chunk-C6WVCZRW.js +19 -0
- package/dist/chunk-HT6BGERF.js +167 -0
- package/dist/{chunk-3QEKIC6C.js → chunk-NGY3TNND.js} +121 -11
- package/dist/{chunk-MFYFZQHM.js → chunk-W3VDIBGR.js} +1 -0
- package/dist/chunk-WJ6FLKNC.js +15 -0
- package/dist/cli.js +9 -0
- package/dist/{cta-logo-JIG22SWA.js → cta-logo-S4OMTNXD.js} +5 -3
- package/dist/{cta-media-GIHQTPPK.js → cta-media-EROTHDRR.js} +5 -3
- package/dist/{incoming-call-2O5NPJ7B.js → incoming-call-5IWJ2TAS.js} +5 -3
- package/dist/{infographic-before-after-FQXCQ2CY.js → infographic-before-after-2I7YZVDD.js} +2 -2
- package/dist/{infographic-feature-list-MVJ3GQVK.js → infographic-feature-list-7SK65U3K.js} +5 -3
- package/dist/{infographic-problem-solution-ZUF54SNF.js → infographic-problem-solution-IW35S6BR.js} +5 -3
- package/dist/{infographic-stat-row-4DLC5NSC.js → infographic-stat-row-XHUZZPDM.js} +5 -3
- package/dist/{infographic-steps-IT23RFV6.js → infographic-steps-JDDEDI7X.js} +5 -3
- package/dist/{preload-media-IYFMPMLB.js → preload-media-LJXWKTWG.js} +2 -0
- package/dist/{prompt-input-IL7HP5WX.js → prompt-input-6ZWXEL3V.js} +5 -3
- package/dist/react.js +30 -28
- package/dist/{reaction-IDFJZ7HI.js → reaction-Z3JIM3MQ.js} +5 -3
- package/dist/scene-video-backdrop-IL4F2SVD.js +9 -0
- package/dist/{showcase-code-EIGXDZE3.js → showcase-code-NWQAAJIA.js} +5 -3
- package/dist/{showcase-phone-STUZJRXV.js → showcase-phone-5UW45ATT.js} +5 -3
- package/dist/{showcase-terminal-SYP6TKP2.js → showcase-terminal-UVOD25UF.js} +5 -3
- package/dist/{showcase-web-57R4RMTG.js → showcase-web-3AW6VHIG.js} +5 -3
- package/dist/{social-milestone-J6QVX4YJ.js → social-milestone-KPKHFOB5.js} +8 -6
- package/dist/{social-notification-2RYS7NM4.js → social-notification-EDOU6TZB.js} +5 -3
- package/dist/{social-review-stack-25C6JJ4W.js → social-review-stack-EZOO33SV.js} +5 -3
- package/dist/{social-testimonial-SH54T4CW.js → social-testimonial-DLOINMMY.js} +5 -3
- package/dist/{social-tweet-T7RTFQ6T.js → social-tweet-6JUM323G.js} +5 -3
- package/examples/nextjs-quickstart/package.json +1 -1
- package/package.json +1 -1
- package/registry/items/backgrounds.json +19 -1
- package/registry/items/barChart.json +2 -2
- package/registry/items/beforeAfter.json +1 -1
- package/registry/items/bigNumber.json +2 -2
- package/registry/items/brandMessage.json +2 -2
- package/registry/items/cardList.json +2 -2
- package/registry/items/codeEditor.json +2 -2
- package/registry/items/confetti.json +1 -1
- package/registry/items/ctaLogo.json +2 -2
- package/registry/items/ctaMedia.json +2 -2
- package/registry/items/emojiBurst.json +1 -1
- package/registry/items/incomingCall.json +2 -2
- package/registry/items/media.json +2 -2
- package/registry/items/milestone.json +2 -2
- package/registry/items/notification.json +2 -2
- package/registry/items/phoneMockup.json +2 -2
- package/registry/items/problemSolution.json +2 -2
- package/registry/items/progressRing.json +2 -2
- package/registry/items/promptInput.json +2 -2
- package/registry/items/reaction.json +2 -2
- package/registry/items/reviewStack.json +2 -2
- package/registry/items/steps.json +2 -2
- package/registry/items/terminal.json +2 -2
- package/registry/items/testimonial.json +2 -2
- package/registry/items/tripleStats.json +2 -2
- package/registry/items/tweet.json +2 -2
- package/registry/items/webMockup.json +2 -2
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"path": "src/visual-system/scene-templates/infographic-steps.tsx",
|
|
20
20
|
"type": "registry:component",
|
|
21
21
|
"target": "vanillasky/scene-templates/infographic-steps.tsx",
|
|
22
|
-
"content": "/**\n * infographic-steps — vertical step-by-step process visualization.\n *\n * Two or three steps form a connected vertical timeline in portrait or a\n * horizontal row in landscape. Animation: the connector draws in reading order,\n * the circles pop in together, and the editable labels follow. Exit cascade\n * slides each step left and fades in reading order.\n *\n * Block structure (docs/blocks.md):\n * background — brand atmosphere or supplied/stock media (SceneBackground)\n * caption — TemplateText headline in the top text area (subtle archetype)\n * hero — StepsList primitive (timeline + labels + cascade exit)\n */\n\nimport React from \"react\";\nimport { parseList } from \"../parse-list\";\nimport type { SceneTemplateProps } from \"./types\";\nimport { resolveTokens } from \"../theme\";\nimport { TemplateText } from \"./template-text\";\nimport { StepsList } from \"../primitives/infographic/StepsList\";\nimport { SceneBackground, getMediaBackgroundProps, hasSceneMedia } from \"./scene-background\";\n\nexport const InfographicStepsTemplate: React.FC<SceneTemplateProps> = ({\n variables,\n style,\n progress,\n motionProgress = progress,\n beatIntensity,\n width,\n height,\n textArchetype,\n safeZone,\n sceneDuration,\n backgroundEffect,\n isPlaying,\n}) => {\n const { primary, foreground, font } = resolveTokens(style);\n const textColor = foreground;\n const textsRaw = String(variables.texts || \"\");\n\n const stepLabels = parseList(variables.steps, 3);\n const stepEmojis = parseList(variables.stepEmojis, 3);\n\n // textArchetype is intentionally unused — the headline always uses the\n // subtle archetype so the step cascade carries the motion.\n void textArchetype;\n\n return (\n <div\n style={{\n width,\n height,\n backgroundColor: \"#000\",\n position: \"relative\",\n overflow: \"hidden\",\n fontFamily: font,\n }}\n >\n {/* [slot: background] Brand atmosphere or cinematic supplied/stock media. */}\n <SceneBackground\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n width={width}\n height={height}\n {...getMediaBackgroundProps(variables)}\n backgroundEffect={backgroundEffect}\n seed={textsRaw}\n isPlaying={isPlaying}\n beatIntensity={beatIntensity}\n />\n\n {/* [slot: caption] Headline — top text area */}\n <TemplateText\n overMedia={hasSceneMedia(variables)}\n motionProgress={motionProgress}\n typeTreatment={resolveTokens(style).preset.type}\n archetype=\"subtle\"\n text={String(variables.texts ?? \"\")}\n progress={progress}\n sceneDuration={sceneDuration ?? 3}\n width={width}\n height={height}\n position=\"top\"\n sizeRole=\"headline\"\n safeZone={safeZone}\n font={font}\n color={textColor}\n beatIntensity={beatIntensity}\n />\n\n {/* [slot: hero] Step circles + titles — shared primitive */}\n <StepsList\n progress={motionProgress}\n width={width}\n height={height}\n steps={stepLabels.map((title) => ({ title }))}\n stepEmojis={stepEmojis}\n accent={primary}\n textColor={textColor}\n font={font}\n beatIntensity={beatIntensity}\n safeZone={safeZone}\n />\n </div>\n );\n};\n"
|
|
22
|
+
"content": "/**\n * infographic-steps — vertical step-by-step process visualization.\n *\n * Two or three steps form a connected vertical timeline in portrait or a\n * horizontal row in landscape. Animation: the connector draws in reading order,\n * the circles pop in together, and the editable labels follow. Exit cascade\n * slides each step left and fades in reading order.\n *\n * Block structure (docs/blocks.md):\n * background — brand atmosphere or supplied/stock media (SceneBackground)\n * caption — TemplateText headline in the top text area (subtle archetype)\n * hero — StepsList primitive (timeline + labels + cascade exit)\n */\n\nimport React from \"react\";\nimport { parseList } from \"../parse-list\";\nimport type { SceneTemplateProps } from \"./types\";\nimport { resolveTokens } from \"../theme\";\nimport { TemplateText } from \"./template-text\";\nimport { StepsList } from \"../primitives/infographic/StepsList\";\nimport { SceneBackground, getMediaBackgroundProps, hasSceneMedia } from \"./scene-background\";\n\nexport const InfographicStepsTemplate: React.FC<SceneTemplateProps> = ({\n variables,\n style,\n progress,\n motionProgress = progress,\n beatIntensity,\n width,\n height,\n textArchetype,\n safeZone,\n sceneDuration,\n backgroundEffect,\n isPlaying,\n}) => {\n const { primary, foreground, font } = resolveTokens(style);\n const textColor = foreground;\n const textsRaw = String(variables.texts || \"\");\n\n const stepLabels = parseList(variables.steps, 3);\n const stepEmojis = parseList(variables.stepEmojis, 3);\n\n // textArchetype is intentionally unused — the headline always uses the\n // subtle archetype so the step cascade carries the motion.\n void textArchetype;\n\n return (\n <div\n style={{\n width,\n height,\n backgroundColor: \"var(--vanillasky-template-surface, #000)\",\n position: \"relative\",\n overflow: \"hidden\",\n fontFamily: font,\n }}\n >\n {/* [slot: background] Brand atmosphere or cinematic supplied/stock media. */}\n <SceneBackground\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n width={width}\n height={height}\n {...getMediaBackgroundProps(variables)}\n backgroundEffect={backgroundEffect}\n seed={textsRaw}\n isPlaying={isPlaying}\n beatIntensity={beatIntensity}\n />\n\n {/* [slot: caption] Headline — top text area */}\n <TemplateText\n overMedia={hasSceneMedia(variables)}\n motionProgress={motionProgress}\n typeTreatment={resolveTokens(style).preset.type}\n archetype=\"subtle\"\n text={String(variables.texts ?? \"\")}\n progress={progress}\n sceneDuration={sceneDuration ?? 3}\n width={width}\n height={height}\n position=\"top\"\n sizeRole=\"headline\"\n safeZone={safeZone}\n font={font}\n color={textColor}\n beatIntensity={beatIntensity}\n />\n\n {/* [slot: hero] Step circles + titles — shared primitive */}\n <StepsList\n progress={motionProgress}\n width={width}\n height={height}\n steps={stepLabels.map((title) => ({ title }))}\n stepEmojis={stepEmojis}\n accent={primary}\n textColor={textColor}\n font={font}\n beatIntensity={beatIntensity}\n safeZone={safeZone}\n />\n </div>\n );\n};\n"
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
"path": "src/visual-system/scene-templates/media-source.ts",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"path": "src/visual-system/scene-templates/scene-background.tsx",
|
|
32
32
|
"type": "registry:component",
|
|
33
33
|
"target": "vanillasky/scene-templates/scene-background.tsx",
|
|
34
|
-
"content": "/**\n * SceneBackground — shared backdrop component for any scene template that\n * wants to support both a brand-color gradient and stock media (Pexels\n * photo / video) as an alternate atmosphere.\n *\n * Usage:\n * <SceneBackground\n * style={style}\n * progress={progress}\n * sceneDuration={sceneDuration}\n * width={width}\n * height={height}\n * mediaUrl={String(variables.mediaUrl || \"\")}\n * mediaType={String(variables.mediaType || \"auto\")}\n * seed={String(variables.texts || \"\")}\n * isPlaying={isPlaying}\n * />\n * ... template's content layered on top\n *\n * Behavior:\n * - Brand gradient is the always-on backdrop (uses BrandGradientOverlay).\n * - When mediaUrl is set and mediaType isn't \"gradient\", the photo/video\n * covers the gradient. Legibility is then split between two instruments:\n * eased scrims shaped to where the template's copy sits (`textAnchor`),\n * and a per-glyph halo on the type itself (MEDIA_TEXT_SHADOW). Neither\n * alone can hold white type over a blown-out highlight without flattening\n * the picture; together they do it at roughly half the darkening.\n * - mediaType=\"gradient\" deliberately ignores mediaUrl and renders only\n * the brand gradient. First-class atmospheric mode.\n * - When mediaUrl is empty, 404s, is blocked, or Pexels search returned\n * nothing, the gradient shows through cleanly and no scrim is painted —\n * a scrim over a bare gradient is just a muddy gradient. Enforced, not\n * assumed: the media has to load before anything darkens for it.\n *\n * Extracted from bg-media.tsx so any template can compose it. bg-media\n * now uses this component too — its \"media is the scene\" identity comes\n * from how it positions the title (centered, full-frame), not from\n * duplicated render logic.\n */\n\nimport React, { useEffect, useRef, useState } from \"react\";\nimport {\n hasSceneMedia,\n limitsConcurrentVideoDecoders,\n resolveMediaType,\n type ResolvedMediaType,\n} from \"./media-source\";\nimport type { TemplateStyle } from \"../template-context\";\nimport { BrandGradientOverlay } from \"../backgrounds\";\nimport { getBackgroundTransform } from \"../backgrounds\";\n\nexport { hasSceneMedia, resolveMediaType };\nexport type { ResolvedMediaType };\n\nexport type MediaPosition = \"center\" | \"top\" | \"bottom\" | \"left\" | \"right\";\nexport type MediaTreatment = \"subtle\" | \"cinematic\" | \"text-safe\";\n\nconst MEDIA_POSITIONS: Record<MediaPosition, string> = {\n center: \"center center\",\n top: \"center top\",\n bottom: \"center bottom\",\n left: \"left center\",\n right: \"right center\",\n};\n\nexport function resolveMediaPosition(value: string): string {\n return MEDIA_POSITIONS[value as MediaPosition] ?? MEDIA_POSITIONS.center;\n}\n\nexport function resolveMediaTreatment(value: string): MediaTreatment {\n return value === \"subtle\" || value === \"text-safe\" ? value : \"cinematic\";\n}\n\nexport interface MediaTreatmentLayer {\n id: \"vignette\" | \"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 const vignette: MediaTreatmentLayer = {\n id: \"vignette\",\n background:\n treatment === \"subtle\"\n ? `radial-gradient(ellipse at center, ${easedStops(0.28, 45, 100, \"fade-in\")})`\n : `radial-gradient(ellipse at center, ${easedStops(0.72, 32, 100, \"fade-in\")})`,\n };\n if (treatment === \"subtle\") return [vignette];\n\n const textSafe = treatment === \"text-safe\";\n const layers: MediaTreatmentLayer[] = [vignette];\n\n if (anchor !== \"bottom\") {\n layers.push({\n id: \"center-scrim\",\n background: textSafe\n ? `radial-gradient(ellipse 92% 58% at 50% 50%, ${easedStops(0.46, 34, 90, \"fade-out\")})`\n : `radial-gradient(ellipse 88% 52% at 50% 50%, ${easedStops(0.26, 30, 88, \"fade-out\")})`,\n });\n }\n\n if (anchor !== \"center\") {\n layers.push({\n id: \"bottom-scrim\",\n background: `linear-gradient(to top, ${easedStops(textSafe ? 0.64 : 0.5, 8, 100, \"fade-out\")})`,\n style: { top: \"55%\" },\n });\n }\n\n return layers;\n}\n\n/**\n * Whether the backdrop is actually painting, which is what decides if a scrim\n * is earned. \"pending\" is a browser-only state: static and export renders\n * never run effects and never wait on a network, so they start (and stay)\n * ready and their output is unchanged.\n */\ntype MediaPaintState = \"pending\" | \"ready\" | \"failed\";\n\nfunction initialMediaPaint(\n wantsMedia: boolean,\n resolved: ResolvedMediaType,\n mediaUrl: string,\n mediaPoster: string | undefined,\n): MediaPaintState {\n if (typeof window === \"undefined\") return \"ready\";\n if (!wantsMedia) return \"ready\";\n // A poster paints the video's frame immediately, so the scene is already\n // showing footage even though the stream is still decoding.\n if (resolved === \"video\") return mediaPoster ? \"ready\" : \"pending\";\n if (typeof Image === \"undefined\") return \"ready\";\n // Preloaded or browser-cached media decodes synchronously. Reporting it\n // ready on the first render keeps the common mid-playback case free of a\n // gradient-then-photo flicker.\n const cached = new Image();\n cached.src = mediaUrl;\n return cached.complete && cached.naturalWidth > 0 ? \"ready\" : \"pending\";\n}\n\nexport function getMediaBackgroundProps(variables: Record<string, unknown>) {\n return {\n mediaUrl: String(variables.mediaUrl || \"\"),\n mediaType: String(variables.mediaType || \"auto\"),\n mediaPoster: String(variables.mediaPoster || \"\"),\n mediaPosition: String(variables.mediaPosition || \"center\"),\n mediaTreatment: String(variables.mediaTreatment || \"cinematic\"),\n };\n}\n\nexport interface SceneBackgroundProps {\n style: TemplateStyle;\n progress: number;\n sceneDuration?: number;\n width: number;\n height: number;\n mediaUrl?: string;\n mediaType?: string;\n /** Still image URL shown while the <video> backdrop decodes its first\n * frame. Without it the element renders transparent during the\n * ~50–400ms decode window and the gradient flashes through. */\n mediaPoster?: string;\n /** Cover-crop focal anchor. Keeps the important edge/subject visible. */\n mediaPosition?: string;\n /** Overlay recipe: subtle, cinematic, or stronger text-safe contrast. */\n mediaTreatment?: string;\n /** Where this template's copy sits, so the scrim is shaped to the type\n * instead of to the frame. Defaults to \"full\" (scrim both the middle and\n * the lower third) for templates that have not declared an anchor. */\n textAnchor?: MediaTextAnchor;\n /** Background motion effect (drift / pulse / Ken Burns). Applied to the photo/video. */\n backgroundEffect?: string;\n /** Stable seed for the gradient breathing animation. Pass the scene's\n * text content (or any stable string) — it's hashed deterministically. */\n seed?: number | string;\n /** Pause video when preview is paused. Defaults to true (export path). */\n isPlaying?: boolean;\n beatIntensity?: number;\n}\n\nexport const SceneBackground: React.FC<SceneBackgroundProps> = ({\n style,\n progress,\n sceneDuration,\n width: _width, // accepted for symmetry; not currently used in render\n height: _height,\n mediaUrl = \"\",\n mediaType = \"auto\",\n mediaPoster,\n mediaPosition = \"center\",\n mediaTreatment = \"cinematic\",\n textAnchor = \"full\",\n backgroundEffect,\n seed,\n isPlaying = true,\n beatIntensity = 0,\n}) => {\n void _width;\n void _height;\n const resolved = resolveMediaType(mediaType, mediaUrl);\n const wantsMedia = resolved !== \"gradient\" && !!mediaUrl;\n\n // A scrim exists to hold type against footage. Until the footage is on\n // screen there is nothing to hold it against, so the scrim would just be\n // darkening the brand gradient it was never meant to touch — the scene\n // reads as a muddy, vignetted version of the gradient scenes beside it.\n // That window is not rare: it covers the whole load, and it never ends for\n // a dead URL, a blocked host, or an empty stock search.\n //\n // So the media has to paint before anything darkens for it. Both edges of\n // the swap land on the same commit — scrim and picture appear together,\n // and the fallback is the clean gradient the docs always promised.\n const [mediaPaint, setMediaPaint] = useState<MediaPaintState>(() =>\n initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster),\n );\n // A stock-video poster is useful only until this exact <video> can paint its\n // own first frame. Desktop incoming scenes decode while opacity is zero, so\n // loadeddata is sufficient there. Mobile WebKit cannot mount that incoming\n // decoder safely; loadeddata can precede the first presented frame and\n // removing the poster in that gap exposes the brand gradient.\n const [decodedVideoUrl, setDecodedVideoUrl] = useState<string>();\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 // Video playback control — same pause/seek logic bg-media used pre-extract.\n const videoRef = useRef<HTMLVideoElement>(null);\n const videoStarted = useRef(false);\n\n useEffect(() => {\n const vid = videoRef.current;\n if (!vid) return;\n if (!isPlaying) {\n vid.pause();\n videoStarted.current = false;\n return;\n }\n if (!videoStarted.current) {\n vid.playbackRate = 1;\n // A fresh element is already at zero. Avoid seeking it before metadata\n // exists; only a resumed scene needs rewinding.\n if (vid.currentTime > 0) vid.currentTime = 0;\n vid.play().catch(() => {});\n videoStarted.current = true;\n }\n }, [progress, isPlaying]);\n\n // Release the decoder on unmount. Without this, iOS Safari keeps the\n // video's decoder buffer alive after the React node is gone — each\n // scene transition (or play/pause/play cycle that remounts the active\n // scene) leaks one decoder, eventually crossing the renderer's memory\n // ceiling and triggering \"A problem repeatedly occurred.\" Same recipe\n // as #409's CanvasPreview preload cleanup: pause → clear src → load().\n // Capture the ref at mount-time so the cleanup uses the same node we\n // mounted (the ref's .current is stale by unmount).\n useEffect(() => {\n const vid = videoRef.current;\n return () => {\n if (!vid) return;\n vid.pause();\n vid.removeAttribute(\"src\");\n vid.load();\n };\n }, []);\n\n return (\n <>\n <BrandGradientOverlay\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n seed={gradSeed}\n />\n\n {showMedia &&\n (resolved === \"video\" ? (\n <video\n ref={videoRef}\n src={mediaUrl}\n // Poster paints during the decode window so the user sees the\n // (still) first frame instead of a transparent <video> letting\n // the brand gradient show through. Pexels returns a thumbnail\n // image alongside each video; fillPexelsUrls stores it in\n // `variables.mediaPoster`. Layered defense alongside preload=\"auto\"\n // below: on desktop the byte preloader makes decode fast, on\n // mobile (where the preloader skips video pre-mounting to dodge\n // the iOS Safari memory crash) the poster is the primary shield.\n poster={decodedVideoUrl === mediaUrl ? undefined : mediaPoster || undefined}\n muted\n loop\n playsInline\n // preload=\"auto\" — without it, browsers default to \"metadata\":\n // they only load the container/dimensions, not the byte stream\n // needed to decode frames. The element then renders transparent\n // until the first decoded frame arrives, letting the brand\n // gradient flash through whenever a scene mid-playback transitions\n // to a media backdrop. The parent preloader caches the bytes, but\n // decoder state is per-element, so the active mount still has to\n // decode the first frame; \"auto\" kicks that work off the instant\n // the element mounts.\n preload=\"auto\"\n onLoadedData={(event) => {\n setMediaPaint(\"ready\");\n const video = event.currentTarget;\n if (!limitsConcurrentVideoDecoders()) {\n setDecodedVideoUrl(mediaUrl);\n return;\n }\n // requestVideoFrameCallback fires only after WebKit submits an\n // actual frame to the compositor. Until then the poster stays\n // in place; browsers without the API safely keep the poster\n // attribute and replace it themselves when playback begins.\n video.requestVideoFrameCallback?.(() => {\n if (video.isConnected) setDecodedVideoUrl(mediaUrl);\n });\n }}\n onError={() => setMediaPaint(\"failed\")}\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: resolvedPosition,\n // Mobile WebKit can briefly make a newly mounted video layer\n // transparent while handing the decoder over from the previous\n // scene. Keep the already-warmed poster painted behind the\n // replaced content so that gap never exposes the gradient.\n backgroundImage: mediaPoster ? `url(${mediaPoster})` : undefined,\n backgroundSize: \"cover\",\n backgroundPosition: resolvedPosition,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n }}\n />\n ) : (\n <div\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n backgroundImage: `url(${mediaUrl})`,\n backgroundSize: \"cover\",\n backgroundPosition: resolvedPosition,\n }}\n />\n ))}\n\n {showTreatment &&\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"
|
|
34
|
+
"content": "/**\n * SceneBackground — shared backdrop component for any scene template that\n * wants to support both a brand-color gradient and stock media (Pexels\n * photo / video) as an alternate atmosphere.\n *\n * Usage:\n * <SceneBackground\n * style={style}\n * progress={progress}\n * sceneDuration={sceneDuration}\n * width={width}\n * height={height}\n * mediaUrl={String(variables.mediaUrl || \"\")}\n * mediaType={String(variables.mediaType || \"auto\")}\n * seed={String(variables.texts || \"\")}\n * isPlaying={isPlaying}\n * />\n * ... template's content layered on top\n *\n * Behavior:\n * - Brand gradient is the always-on backdrop (uses BrandGradientOverlay).\n * - When mediaUrl is set and mediaType isn't \"gradient\", the photo/video\n * covers the gradient. Legibility is then split between two instruments:\n * eased scrims shaped to where the template's copy sits (`textAnchor`),\n * and a per-glyph halo on the type itself (MEDIA_TEXT_SHADOW). Neither\n * alone can hold white type over a blown-out highlight without flattening\n * the picture; together they do it at roughly half the darkening.\n * - mediaType=\"gradient\" deliberately ignores mediaUrl and renders only\n * the brand gradient. First-class atmospheric mode.\n * - When mediaUrl is empty, 404s, is blocked, or Pexels search returned\n * nothing, the gradient shows through cleanly and no scrim is painted —\n * a scrim over a bare gradient is just a muddy gradient. Enforced, not\n * assumed: the media has to load before anything darkens for it.\n *\n * Extracted from bg-media.tsx so any template can compose it. bg-media\n * now uses this component too — its \"media is the scene\" identity comes\n * from how it positions the title (centered, full-frame), not from\n * duplicated render logic.\n */\n\nimport React, { useEffect, useState } from \"react\";\nimport {\n hasSceneMedia,\n resolveMediaType,\n type ResolvedMediaType,\n} from \"./media-source\";\nimport type { TemplateStyle } from \"../template-context\";\nimport { BrandGradientOverlay } from \"../backgrounds\";\nimport { getBackgroundTransform } from \"../backgrounds\";\nimport { useExternalVideoBackdrop } from \"./external-video-backdrop\";\nimport { resolveMediaPosition, type MediaPosition } from \"./media-position\";\nimport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\n\nexport { hasSceneMedia, resolveMediaType };\nexport { resolveMediaPosition } from \"./media-position\";\nexport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\nexport type { ResolvedMediaType };\n\nexport type { MediaPosition };\nexport type MediaTreatment = \"subtle\" | \"cinematic\" | \"text-safe\";\n\nexport function resolveMediaTreatment(value: string): MediaTreatment {\n return 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 const vignette: MediaTreatmentLayer = {\n id: \"vignette\",\n background:\n treatment === \"subtle\"\n ? `radial-gradient(ellipse at center, ${easedStops(0.28, 45, 100, \"fade-in\")})`\n : `radial-gradient(ellipse at center, ${easedStops(0.72, 32, 100, \"fade-in\")})`,\n };\n if (treatment === \"subtle\") return [vignette];\n\n const textSafe = treatment === \"text-safe\";\n const layers: MediaTreatmentLayer[] = [vignette];\n\n if (anchor !== \"bottom\") {\n layers.push({\n id: \"center-scrim\",\n background: textSafe\n ? `radial-gradient(ellipse 92% 58% at 50% 50%, ${easedStops(0.46, 34, 90, \"fade-out\")})`\n : `radial-gradient(ellipse 88% 52% at 50% 50%, ${easedStops(0.26, 30, 88, \"fade-out\")})`,\n });\n }\n\n if (anchor !== \"center\") {\n layers.push({\n id: \"bottom-scrim\",\n background: `linear-gradient(to top, ${easedStops(textSafe ? 0.64 : 0.5, 8, 100, \"fade-out\")})`,\n style: { top: \"55%\" },\n });\n }\n\n return layers;\n}\n\n/**\n * Whether the backdrop is actually painting, which is what decides if a scrim\n * is earned. \"pending\" is a browser-only state: static and export renders\n * never run effects and never wait on a network, so they start (and stay)\n * ready and their output is unchanged.\n */\ntype MediaPaintState = \"pending\" | \"ready\" | \"failed\";\n\nfunction initialMediaPaint(\n wantsMedia: boolean,\n resolved: ResolvedMediaType,\n mediaUrl: string,\n mediaPoster: string | undefined,\n): MediaPaintState {\n if (typeof window === \"undefined\") return \"ready\";\n if (!wantsMedia) return \"ready\";\n // A poster paints the video's frame immediately, so the scene is already\n // showing footage even though the stream is still decoding.\n if (resolved === \"video\") return mediaPoster ? \"ready\" : \"pending\";\n if (typeof Image === \"undefined\") return \"ready\";\n // Preloaded or browser-cached media decodes synchronously. Reporting it\n // ready on the first render keeps the common mid-playback case free of a\n // gradient-then-photo flicker.\n const cached = new Image();\n cached.src = mediaUrl;\n return cached.complete && cached.naturalWidth > 0 ? \"ready\" : \"pending\";\n}\n\nexport function getMediaBackgroundProps(variables: Record<string, unknown>) {\n return {\n mediaUrl: String(variables.mediaUrl || \"\"),\n mediaType: String(variables.mediaType || \"auto\"),\n mediaPoster: String(variables.mediaPoster || \"\"),\n mediaPosition: String(variables.mediaPosition || \"center\"),\n mediaTreatment: String(variables.mediaTreatment || \"cinematic\"),\n };\n}\n\nexport interface SceneBackgroundProps {\n style: TemplateStyle;\n progress: number;\n sceneDuration?: number;\n width: number;\n height: number;\n mediaUrl?: string;\n mediaType?: string;\n /** Still image URL shown while the <video> backdrop decodes its first\n * frame. Without it the element renders transparent during the\n * ~50–400ms decode window and the gradient flashes through. */\n mediaPoster?: string;\n /** Cover-crop focal anchor. Keeps the important edge/subject visible. */\n mediaPosition?: string;\n /** Overlay recipe: subtle, cinematic, or stronger text-safe contrast. */\n mediaTreatment?: string;\n /** Where this template's copy sits, so the scrim is shaped to the type\n * instead of to the frame. Defaults to \"full\" (scrim both the middle and\n * the lower third) for templates that have not declared an anchor. */\n textAnchor?: MediaTextAnchor;\n /** Background motion effect (drift / pulse / Ken Burns). Applied to the photo/video. */\n backgroundEffect?: string;\n /** Stable seed for the gradient breathing animation. Pass the scene's\n * text content (or any stable string) — it's hashed deterministically. */\n seed?: number | string;\n /** Pause video when preview is paused. Defaults to true (export path). */\n isPlaying?: boolean;\n beatIntensity?: number;\n}\n\nexport const SceneBackground: React.FC<SceneBackgroundProps> = ({\n style,\n progress,\n sceneDuration,\n width: _width, // accepted for symmetry; not currently used in render\n height: _height,\n mediaUrl = \"\",\n mediaType = \"auto\",\n mediaPoster,\n mediaPosition = \"center\",\n mediaTreatment = \"cinematic\",\n textAnchor = \"full\",\n backgroundEffect,\n seed,\n isPlaying = true,\n beatIntensity = 0,\n}) => {\n void _width;\n void _height;\n const resolved = resolveMediaType(mediaType, mediaUrl);\n const wantsMedia = resolved !== \"gradient\" && !!mediaUrl;\n const externalVideoBackdrop = useExternalVideoBackdrop();\n const hasExternalVideoBackdrop = externalVideoBackdrop !== false && resolved === \"video\";\n const externalVideoFailed = externalVideoBackdrop === \"fallback\" && resolved === \"video\";\n const externalVideoReady = externalVideoBackdrop === \"ready\" && resolved === \"video\";\n\n // A scrim exists to hold type against footage. Until the footage is on\n // screen there is nothing to hold it against, so the scrim would just be\n // darkening the brand gradient it was never meant to touch — the scene\n // reads as a muddy, vignetted version of the gradient scenes beside it.\n // That window is not rare: it covers the whole load, and it never ends for\n // a dead URL, a blocked host, or an empty stock search.\n //\n // So the media has to paint before anything darkens for it. Both edges of\n // the swap land on the same commit — scrim and picture appear together,\n // and the fallback is the clean gradient the docs always promised.\n const [mediaPaint, setMediaPaint] = useState<MediaPaintState>(() =>\n initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster),\n );\n useEffect(() => {\n setMediaPaint(initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster));\n // Video reports its own paint through onLoadedData / onError below.\n if (!wantsMedia || resolved !== \"photo\") return;\n if (typeof Image === \"undefined\") return;\n let cancelled = false;\n const probe = new Image();\n probe.onload = () => {\n if (!cancelled) setMediaPaint(\"ready\");\n };\n probe.onerror = () => {\n if (!cancelled) setMediaPaint(\"failed\");\n };\n probe.src = mediaUrl;\n if (probe.complete) setMediaPaint(probe.naturalWidth > 0 ? \"ready\" : \"failed\");\n return () => {\n cancelled = true;\n probe.onload = null;\n probe.onerror = null;\n };\n }, [mediaUrl, mediaPoster, resolved, wantsMedia]);\n\n // The element stays mounted while pending — that is what loads it. Only a\n // confirmed failure takes it back out.\n const showMedia = wantsMedia && mediaPaint !== \"failed\";\n const showTreatment = wantsMedia && mediaPaint === \"ready\";\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const resolvedTreatment = resolveMediaTreatment(mediaTreatment);\n const treatmentLayers = getMediaTreatmentLayers(resolvedTreatment, textAnchor);\n\n const gradSeed =\n typeof seed === \"number\"\n ? seed\n : typeof seed === \"string\"\n ? seed.split(\"\").reduce((acc, c) => acc + c.charCodeAt(0), 0)\n : 0;\n\n const bgTransform = getBackgroundTransform(\n backgroundEffect,\n progress,\n beatIntensity,\n );\n\n return (\n <>\n {(!hasExternalVideoBackdrop || externalVideoFailed) && (\n <BrandGradientOverlay\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n seed={gradSeed}\n />\n )}\n\n {showMedia && !hasExternalVideoBackdrop &&\n (resolved === \"video\" ? (\n <SceneVideoBackdrop\n mediaUrl={mediaUrl}\n mediaPoster={mediaPoster}\n mediaPosition={mediaPosition}\n backgroundEffect={backgroundEffect}\n progress={progress}\n beatIntensity={beatIntensity}\n isPlaying={isPlaying}\n onReady={() => setMediaPaint(\"ready\")}\n onError={() => setMediaPaint(\"failed\")}\n />\n ) : (\n <div\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n backgroundImage: `url(${mediaUrl})`,\n backgroundSize: \"cover\",\n backgroundPosition: resolvedPosition,\n }}\n />\n ))}\n\n {(hasExternalVideoBackdrop ? externalVideoReady : showTreatment) && !externalVideoFailed &&\n treatmentLayers.map((layer) => (\n <div\n key={layer.id}\n data-media-treatment={resolvedTreatment}\n data-media-overlay={layer.id}\n style={{\n position: \"absolute\",\n inset: 0,\n background: layer.background,\n pointerEvents: \"none\",\n ...layer.style,\n }}\n />\n ))}\n </>\n );\n};\n"
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"path": "src/visual-system/primitives/infographic/StepsList.tsx",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"path": "src/visual-system/scene-templates/showcase-terminal.tsx",
|
|
20
20
|
"type": "registry:component",
|
|
21
21
|
"target": "vanillasky/scene-templates/showcase-terminal.tsx",
|
|
22
|
-
"content": "/**\n * showcase-terminal — terminal window with typing animation.\n *\n * macOS-style terminal frame with traffic-light dots. Command types\n * out character by character, then output lines stagger in below.\n * All animation progress-driven. Cursor blinks via progress.\n *\n * The terminal frame itself lives in the TerminalOutput primitive\n * (src/visual-system/primitives/devices/TerminalOutput.tsx) — shared with\n * custom scenes. It owns shrink-to-fit + ellipsis for long commands\n * and output lines so they never clip mid-token at the frame edge.\n */\n\nimport React from \"react\";\nimport { parseList } from \"../parse-list\";\nimport type { SceneTemplateProps } from \"./types\";\nimport { resolveTokens } from \"../theme\";\nimport { TemplateText } from \"./template-text\";\nimport { TerminalOutput } from \"../primitives/devices/TerminalOutput\";\nimport { SceneBackground, getMediaBackgroundProps, hasSceneMedia } from \"./scene-background\";\n\nexport const ShowcaseTerminalTemplate: React.FC<SceneTemplateProps> = ({\n variables,\n style,\n progress,\n motionProgress = progress,\n beatIntensity,\n width,\n height,\n safeZone,\n sceneDuration,\n backgroundEffect,\n isPlaying = true,\n}) => {\n const { primary, foreground, font } = resolveTokens(style);\n const textColor = foreground;\n const textsRaw = String(variables.texts || \"\");\n const command = String(variables.command || \"\");\n const promptPrefix = String(variables.promptPrefix || \"$\");\n const outputLines = parseList(variables.output);\n\n return (\n <div\n style={{\n width,\n height,\n backgroundColor: \"#000\",\n position: \"relative\",\n overflow: \"hidden\",\n fontFamily: font,\n }}\n >\n {/* [slot: background] */}\n <SceneBackground\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n width={width}\n height={height}\n {...getMediaBackgroundProps(variables)}\n backgroundEffect={backgroundEffect}\n seed={textsRaw}\n isPlaying={isPlaying}\n beatIntensity={beatIntensity}\n />\n\n {/* [slot: caption] */}\n <TemplateText\n overMedia={hasSceneMedia(variables)}\n motionProgress={motionProgress}\n typeTreatment={resolveTokens(style).preset.type}\n archetype=\"subtle\"\n text={String(variables.texts ?? \"\")}\n progress={progress}\n sceneDuration={sceneDuration ?? 3}\n width={width}\n height={height}\n position=\"top\"\n sizeRole=\"headline\"\n safeZone={safeZone}\n font={font}\n color={textColor}\n beatIntensity={beatIntensity}\n />\n\n {/* [slot: hero] */}\n <TerminalOutput\n progress={progress}\n command={command}\n outputLines={outputLines}\n promptPrefix={promptPrefix}\n width={width}\n height={height}\n accent={primary}\n beatIntensity={beatIntensity}\n />\n </div>\n );\n};\n"
|
|
22
|
+
"content": "/**\n * showcase-terminal — terminal window with typing animation.\n *\n * macOS-style terminal frame with traffic-light dots. Command types\n * out character by character, then output lines stagger in below.\n * All animation progress-driven. Cursor blinks via progress.\n *\n * The terminal frame itself lives in the TerminalOutput primitive\n * (src/visual-system/primitives/devices/TerminalOutput.tsx) — shared with\n * custom scenes. It owns shrink-to-fit + ellipsis for long commands\n * and output lines so they never clip mid-token at the frame edge.\n */\n\nimport React from \"react\";\nimport { parseList } from \"../parse-list\";\nimport type { SceneTemplateProps } from \"./types\";\nimport { resolveTokens } from \"../theme\";\nimport { TemplateText } from \"./template-text\";\nimport { TerminalOutput } from \"../primitives/devices/TerminalOutput\";\nimport { SceneBackground, getMediaBackgroundProps, hasSceneMedia } from \"./scene-background\";\n\nexport const ShowcaseTerminalTemplate: React.FC<SceneTemplateProps> = ({\n variables,\n style,\n progress,\n motionProgress = progress,\n beatIntensity,\n width,\n height,\n safeZone,\n sceneDuration,\n backgroundEffect,\n isPlaying = true,\n}) => {\n const { primary, foreground, font } = resolveTokens(style);\n const textColor = foreground;\n const textsRaw = String(variables.texts || \"\");\n const command = String(variables.command || \"\");\n const promptPrefix = String(variables.promptPrefix || \"$\");\n const outputLines = parseList(variables.output);\n\n return (\n <div\n style={{\n width,\n height,\n backgroundColor: \"var(--vanillasky-template-surface, #000)\",\n position: \"relative\",\n overflow: \"hidden\",\n fontFamily: font,\n }}\n >\n {/* [slot: background] */}\n <SceneBackground\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n width={width}\n height={height}\n {...getMediaBackgroundProps(variables)}\n backgroundEffect={backgroundEffect}\n seed={textsRaw}\n isPlaying={isPlaying}\n beatIntensity={beatIntensity}\n />\n\n {/* [slot: caption] */}\n <TemplateText\n overMedia={hasSceneMedia(variables)}\n motionProgress={motionProgress}\n typeTreatment={resolveTokens(style).preset.type}\n archetype=\"subtle\"\n text={String(variables.texts ?? \"\")}\n progress={progress}\n sceneDuration={sceneDuration ?? 3}\n width={width}\n height={height}\n position=\"top\"\n sizeRole=\"headline\"\n safeZone={safeZone}\n font={font}\n color={textColor}\n beatIntensity={beatIntensity}\n />\n\n {/* [slot: hero] */}\n <TerminalOutput\n progress={progress}\n command={command}\n outputLines={outputLines}\n promptPrefix={promptPrefix}\n width={width}\n height={height}\n accent={primary}\n beatIntensity={beatIntensity}\n />\n </div>\n );\n};\n"
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
"path": "src/visual-system/scene-templates/media-source.ts",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"path": "src/visual-system/scene-templates/scene-background.tsx",
|
|
32
32
|
"type": "registry:component",
|
|
33
33
|
"target": "vanillasky/scene-templates/scene-background.tsx",
|
|
34
|
-
"content": "/**\n * SceneBackground — shared backdrop component for any scene template that\n * wants to support both a brand-color gradient and stock media (Pexels\n * photo / video) as an alternate atmosphere.\n *\n * Usage:\n * <SceneBackground\n * style={style}\n * progress={progress}\n * sceneDuration={sceneDuration}\n * width={width}\n * height={height}\n * mediaUrl={String(variables.mediaUrl || \"\")}\n * mediaType={String(variables.mediaType || \"auto\")}\n * seed={String(variables.texts || \"\")}\n * isPlaying={isPlaying}\n * />\n * ... template's content layered on top\n *\n * Behavior:\n * - Brand gradient is the always-on backdrop (uses BrandGradientOverlay).\n * - When mediaUrl is set and mediaType isn't \"gradient\", the photo/video\n * covers the gradient. Legibility is then split between two instruments:\n * eased scrims shaped to where the template's copy sits (`textAnchor`),\n * and a per-glyph halo on the type itself (MEDIA_TEXT_SHADOW). Neither\n * alone can hold white type over a blown-out highlight without flattening\n * the picture; together they do it at roughly half the darkening.\n * - mediaType=\"gradient\" deliberately ignores mediaUrl and renders only\n * the brand gradient. First-class atmospheric mode.\n * - When mediaUrl is empty, 404s, is blocked, or Pexels search returned\n * nothing, the gradient shows through cleanly and no scrim is painted —\n * a scrim over a bare gradient is just a muddy gradient. Enforced, not\n * assumed: the media has to load before anything darkens for it.\n *\n * Extracted from bg-media.tsx so any template can compose it. bg-media\n * now uses this component too — its \"media is the scene\" identity comes\n * from how it positions the title (centered, full-frame), not from\n * duplicated render logic.\n */\n\nimport React, { useEffect, useRef, useState } from \"react\";\nimport {\n hasSceneMedia,\n limitsConcurrentVideoDecoders,\n resolveMediaType,\n type ResolvedMediaType,\n} from \"./media-source\";\nimport type { TemplateStyle } from \"../template-context\";\nimport { BrandGradientOverlay } from \"../backgrounds\";\nimport { getBackgroundTransform } from \"../backgrounds\";\n\nexport { hasSceneMedia, resolveMediaType };\nexport type { ResolvedMediaType };\n\nexport type MediaPosition = \"center\" | \"top\" | \"bottom\" | \"left\" | \"right\";\nexport type MediaTreatment = \"subtle\" | \"cinematic\" | \"text-safe\";\n\nconst MEDIA_POSITIONS: Record<MediaPosition, string> = {\n center: \"center center\",\n top: \"center top\",\n bottom: \"center bottom\",\n left: \"left center\",\n right: \"right center\",\n};\n\nexport function resolveMediaPosition(value: string): string {\n return MEDIA_POSITIONS[value as MediaPosition] ?? MEDIA_POSITIONS.center;\n}\n\nexport function resolveMediaTreatment(value: string): MediaTreatment {\n return value === \"subtle\" || value === \"text-safe\" ? value : \"cinematic\";\n}\n\nexport interface MediaTreatmentLayer {\n id: \"vignette\" | \"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 const vignette: MediaTreatmentLayer = {\n id: \"vignette\",\n background:\n treatment === \"subtle\"\n ? `radial-gradient(ellipse at center, ${easedStops(0.28, 45, 100, \"fade-in\")})`\n : `radial-gradient(ellipse at center, ${easedStops(0.72, 32, 100, \"fade-in\")})`,\n };\n if (treatment === \"subtle\") return [vignette];\n\n const textSafe = treatment === \"text-safe\";\n const layers: MediaTreatmentLayer[] = [vignette];\n\n if (anchor !== \"bottom\") {\n layers.push({\n id: \"center-scrim\",\n background: textSafe\n ? `radial-gradient(ellipse 92% 58% at 50% 50%, ${easedStops(0.46, 34, 90, \"fade-out\")})`\n : `radial-gradient(ellipse 88% 52% at 50% 50%, ${easedStops(0.26, 30, 88, \"fade-out\")})`,\n });\n }\n\n if (anchor !== \"center\") {\n layers.push({\n id: \"bottom-scrim\",\n background: `linear-gradient(to top, ${easedStops(textSafe ? 0.64 : 0.5, 8, 100, \"fade-out\")})`,\n style: { top: \"55%\" },\n });\n }\n\n return layers;\n}\n\n/**\n * Whether the backdrop is actually painting, which is what decides if a scrim\n * is earned. \"pending\" is a browser-only state: static and export renders\n * never run effects and never wait on a network, so they start (and stay)\n * ready and their output is unchanged.\n */\ntype MediaPaintState = \"pending\" | \"ready\" | \"failed\";\n\nfunction initialMediaPaint(\n wantsMedia: boolean,\n resolved: ResolvedMediaType,\n mediaUrl: string,\n mediaPoster: string | undefined,\n): MediaPaintState {\n if (typeof window === \"undefined\") return \"ready\";\n if (!wantsMedia) return \"ready\";\n // A poster paints the video's frame immediately, so the scene is already\n // showing footage even though the stream is still decoding.\n if (resolved === \"video\") return mediaPoster ? \"ready\" : \"pending\";\n if (typeof Image === \"undefined\") return \"ready\";\n // Preloaded or browser-cached media decodes synchronously. Reporting it\n // ready on the first render keeps the common mid-playback case free of a\n // gradient-then-photo flicker.\n const cached = new Image();\n cached.src = mediaUrl;\n return cached.complete && cached.naturalWidth > 0 ? \"ready\" : \"pending\";\n}\n\nexport function getMediaBackgroundProps(variables: Record<string, unknown>) {\n return {\n mediaUrl: String(variables.mediaUrl || \"\"),\n mediaType: String(variables.mediaType || \"auto\"),\n mediaPoster: String(variables.mediaPoster || \"\"),\n mediaPosition: String(variables.mediaPosition || \"center\"),\n mediaTreatment: String(variables.mediaTreatment || \"cinematic\"),\n };\n}\n\nexport interface SceneBackgroundProps {\n style: TemplateStyle;\n progress: number;\n sceneDuration?: number;\n width: number;\n height: number;\n mediaUrl?: string;\n mediaType?: string;\n /** Still image URL shown while the <video> backdrop decodes its first\n * frame. Without it the element renders transparent during the\n * ~50–400ms decode window and the gradient flashes through. */\n mediaPoster?: string;\n /** Cover-crop focal anchor. Keeps the important edge/subject visible. */\n mediaPosition?: string;\n /** Overlay recipe: subtle, cinematic, or stronger text-safe contrast. */\n mediaTreatment?: string;\n /** Where this template's copy sits, so the scrim is shaped to the type\n * instead of to the frame. Defaults to \"full\" (scrim both the middle and\n * the lower third) for templates that have not declared an anchor. */\n textAnchor?: MediaTextAnchor;\n /** Background motion effect (drift / pulse / Ken Burns). Applied to the photo/video. */\n backgroundEffect?: string;\n /** Stable seed for the gradient breathing animation. Pass the scene's\n * text content (or any stable string) — it's hashed deterministically. */\n seed?: number | string;\n /** Pause video when preview is paused. Defaults to true (export path). */\n isPlaying?: boolean;\n beatIntensity?: number;\n}\n\nexport const SceneBackground: React.FC<SceneBackgroundProps> = ({\n style,\n progress,\n sceneDuration,\n width: _width, // accepted for symmetry; not currently used in render\n height: _height,\n mediaUrl = \"\",\n mediaType = \"auto\",\n mediaPoster,\n mediaPosition = \"center\",\n mediaTreatment = \"cinematic\",\n textAnchor = \"full\",\n backgroundEffect,\n seed,\n isPlaying = true,\n beatIntensity = 0,\n}) => {\n void _width;\n void _height;\n const resolved = resolveMediaType(mediaType, mediaUrl);\n const wantsMedia = resolved !== \"gradient\" && !!mediaUrl;\n\n // A scrim exists to hold type against footage. Until the footage is on\n // screen there is nothing to hold it against, so the scrim would just be\n // darkening the brand gradient it was never meant to touch — the scene\n // reads as a muddy, vignetted version of the gradient scenes beside it.\n // That window is not rare: it covers the whole load, and it never ends for\n // a dead URL, a blocked host, or an empty stock search.\n //\n // So the media has to paint before anything darkens for it. Both edges of\n // the swap land on the same commit — scrim and picture appear together,\n // and the fallback is the clean gradient the docs always promised.\n const [mediaPaint, setMediaPaint] = useState<MediaPaintState>(() =>\n initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster),\n );\n // A stock-video poster is useful only until this exact <video> can paint its\n // own first frame. Desktop incoming scenes decode while opacity is zero, so\n // loadeddata is sufficient there. Mobile WebKit cannot mount that incoming\n // decoder safely; loadeddata can precede the first presented frame and\n // removing the poster in that gap exposes the brand gradient.\n const [decodedVideoUrl, setDecodedVideoUrl] = useState<string>();\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 // Video playback control — same pause/seek logic bg-media used pre-extract.\n const videoRef = useRef<HTMLVideoElement>(null);\n const videoStarted = useRef(false);\n\n useEffect(() => {\n const vid = videoRef.current;\n if (!vid) return;\n if (!isPlaying) {\n vid.pause();\n videoStarted.current = false;\n return;\n }\n if (!videoStarted.current) {\n vid.playbackRate = 1;\n // A fresh element is already at zero. Avoid seeking it before metadata\n // exists; only a resumed scene needs rewinding.\n if (vid.currentTime > 0) vid.currentTime = 0;\n vid.play().catch(() => {});\n videoStarted.current = true;\n }\n }, [progress, isPlaying]);\n\n // Release the decoder on unmount. Without this, iOS Safari keeps the\n // video's decoder buffer alive after the React node is gone — each\n // scene transition (or play/pause/play cycle that remounts the active\n // scene) leaks one decoder, eventually crossing the renderer's memory\n // ceiling and triggering \"A problem repeatedly occurred.\" Same recipe\n // as #409's CanvasPreview preload cleanup: pause → clear src → load().\n // Capture the ref at mount-time so the cleanup uses the same node we\n // mounted (the ref's .current is stale by unmount).\n useEffect(() => {\n const vid = videoRef.current;\n return () => {\n if (!vid) return;\n vid.pause();\n vid.removeAttribute(\"src\");\n vid.load();\n };\n }, []);\n\n return (\n <>\n <BrandGradientOverlay\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n seed={gradSeed}\n />\n\n {showMedia &&\n (resolved === \"video\" ? (\n <video\n ref={videoRef}\n src={mediaUrl}\n // Poster paints during the decode window so the user sees the\n // (still) first frame instead of a transparent <video> letting\n // the brand gradient show through. Pexels returns a thumbnail\n // image alongside each video; fillPexelsUrls stores it in\n // `variables.mediaPoster`. Layered defense alongside preload=\"auto\"\n // below: on desktop the byte preloader makes decode fast, on\n // mobile (where the preloader skips video pre-mounting to dodge\n // the iOS Safari memory crash) the poster is the primary shield.\n poster={decodedVideoUrl === mediaUrl ? undefined : mediaPoster || undefined}\n muted\n loop\n playsInline\n // preload=\"auto\" — without it, browsers default to \"metadata\":\n // they only load the container/dimensions, not the byte stream\n // needed to decode frames. The element then renders transparent\n // until the first decoded frame arrives, letting the brand\n // gradient flash through whenever a scene mid-playback transitions\n // to a media backdrop. The parent preloader caches the bytes, but\n // decoder state is per-element, so the active mount still has to\n // decode the first frame; \"auto\" kicks that work off the instant\n // the element mounts.\n preload=\"auto\"\n onLoadedData={(event) => {\n setMediaPaint(\"ready\");\n const video = event.currentTarget;\n if (!limitsConcurrentVideoDecoders()) {\n setDecodedVideoUrl(mediaUrl);\n return;\n }\n // requestVideoFrameCallback fires only after WebKit submits an\n // actual frame to the compositor. Until then the poster stays\n // in place; browsers without the API safely keep the poster\n // attribute and replace it themselves when playback begins.\n video.requestVideoFrameCallback?.(() => {\n if (video.isConnected) setDecodedVideoUrl(mediaUrl);\n });\n }}\n onError={() => setMediaPaint(\"failed\")}\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: resolvedPosition,\n // Mobile WebKit can briefly make a newly mounted video layer\n // transparent while handing the decoder over from the previous\n // scene. Keep the already-warmed poster painted behind the\n // replaced content so that gap never exposes the gradient.\n backgroundImage: mediaPoster ? `url(${mediaPoster})` : undefined,\n backgroundSize: \"cover\",\n backgroundPosition: resolvedPosition,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n }}\n />\n ) : (\n <div\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n backgroundImage: `url(${mediaUrl})`,\n backgroundSize: \"cover\",\n backgroundPosition: resolvedPosition,\n }}\n />\n ))}\n\n {showTreatment &&\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"
|
|
34
|
+
"content": "/**\n * SceneBackground — shared backdrop component for any scene template that\n * wants to support both a brand-color gradient and stock media (Pexels\n * photo / video) as an alternate atmosphere.\n *\n * Usage:\n * <SceneBackground\n * style={style}\n * progress={progress}\n * sceneDuration={sceneDuration}\n * width={width}\n * height={height}\n * mediaUrl={String(variables.mediaUrl || \"\")}\n * mediaType={String(variables.mediaType || \"auto\")}\n * seed={String(variables.texts || \"\")}\n * isPlaying={isPlaying}\n * />\n * ... template's content layered on top\n *\n * Behavior:\n * - Brand gradient is the always-on backdrop (uses BrandGradientOverlay).\n * - When mediaUrl is set and mediaType isn't \"gradient\", the photo/video\n * covers the gradient. Legibility is then split between two instruments:\n * eased scrims shaped to where the template's copy sits (`textAnchor`),\n * and a per-glyph halo on the type itself (MEDIA_TEXT_SHADOW). Neither\n * alone can hold white type over a blown-out highlight without flattening\n * the picture; together they do it at roughly half the darkening.\n * - mediaType=\"gradient\" deliberately ignores mediaUrl and renders only\n * the brand gradient. First-class atmospheric mode.\n * - When mediaUrl is empty, 404s, is blocked, or Pexels search returned\n * nothing, the gradient shows through cleanly and no scrim is painted —\n * a scrim over a bare gradient is just a muddy gradient. Enforced, not\n * assumed: the media has to load before anything darkens for it.\n *\n * Extracted from bg-media.tsx so any template can compose it. bg-media\n * now uses this component too — its \"media is the scene\" identity comes\n * from how it positions the title (centered, full-frame), not from\n * duplicated render logic.\n */\n\nimport React, { useEffect, useState } from \"react\";\nimport {\n hasSceneMedia,\n resolveMediaType,\n type ResolvedMediaType,\n} from \"./media-source\";\nimport type { TemplateStyle } from \"../template-context\";\nimport { BrandGradientOverlay } from \"../backgrounds\";\nimport { getBackgroundTransform } from \"../backgrounds\";\nimport { useExternalVideoBackdrop } from \"./external-video-backdrop\";\nimport { resolveMediaPosition, type MediaPosition } from \"./media-position\";\nimport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\n\nexport { hasSceneMedia, resolveMediaType };\nexport { resolveMediaPosition } from \"./media-position\";\nexport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\nexport type { ResolvedMediaType };\n\nexport type { MediaPosition };\nexport type MediaTreatment = \"subtle\" | \"cinematic\" | \"text-safe\";\n\nexport function resolveMediaTreatment(value: string): MediaTreatment {\n return 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 const vignette: MediaTreatmentLayer = {\n id: \"vignette\",\n background:\n treatment === \"subtle\"\n ? `radial-gradient(ellipse at center, ${easedStops(0.28, 45, 100, \"fade-in\")})`\n : `radial-gradient(ellipse at center, ${easedStops(0.72, 32, 100, \"fade-in\")})`,\n };\n if (treatment === \"subtle\") return [vignette];\n\n const textSafe = treatment === \"text-safe\";\n const layers: MediaTreatmentLayer[] = [vignette];\n\n if (anchor !== \"bottom\") {\n layers.push({\n id: \"center-scrim\",\n background: textSafe\n ? `radial-gradient(ellipse 92% 58% at 50% 50%, ${easedStops(0.46, 34, 90, \"fade-out\")})`\n : `radial-gradient(ellipse 88% 52% at 50% 50%, ${easedStops(0.26, 30, 88, \"fade-out\")})`,\n });\n }\n\n if (anchor !== \"center\") {\n layers.push({\n id: \"bottom-scrim\",\n background: `linear-gradient(to top, ${easedStops(textSafe ? 0.64 : 0.5, 8, 100, \"fade-out\")})`,\n style: { top: \"55%\" },\n });\n }\n\n return layers;\n}\n\n/**\n * Whether the backdrop is actually painting, which is what decides if a scrim\n * is earned. \"pending\" is a browser-only state: static and export renders\n * never run effects and never wait on a network, so they start (and stay)\n * ready and their output is unchanged.\n */\ntype MediaPaintState = \"pending\" | \"ready\" | \"failed\";\n\nfunction initialMediaPaint(\n wantsMedia: boolean,\n resolved: ResolvedMediaType,\n mediaUrl: string,\n mediaPoster: string | undefined,\n): MediaPaintState {\n if (typeof window === \"undefined\") return \"ready\";\n if (!wantsMedia) return \"ready\";\n // A poster paints the video's frame immediately, so the scene is already\n // showing footage even though the stream is still decoding.\n if (resolved === \"video\") return mediaPoster ? \"ready\" : \"pending\";\n if (typeof Image === \"undefined\") return \"ready\";\n // Preloaded or browser-cached media decodes synchronously. Reporting it\n // ready on the first render keeps the common mid-playback case free of a\n // gradient-then-photo flicker.\n const cached = new Image();\n cached.src = mediaUrl;\n return cached.complete && cached.naturalWidth > 0 ? \"ready\" : \"pending\";\n}\n\nexport function getMediaBackgroundProps(variables: Record<string, unknown>) {\n return {\n mediaUrl: String(variables.mediaUrl || \"\"),\n mediaType: String(variables.mediaType || \"auto\"),\n mediaPoster: String(variables.mediaPoster || \"\"),\n mediaPosition: String(variables.mediaPosition || \"center\"),\n mediaTreatment: String(variables.mediaTreatment || \"cinematic\"),\n };\n}\n\nexport interface SceneBackgroundProps {\n style: TemplateStyle;\n progress: number;\n sceneDuration?: number;\n width: number;\n height: number;\n mediaUrl?: string;\n mediaType?: string;\n /** Still image URL shown while the <video> backdrop decodes its first\n * frame. Without it the element renders transparent during the\n * ~50–400ms decode window and the gradient flashes through. */\n mediaPoster?: string;\n /** Cover-crop focal anchor. Keeps the important edge/subject visible. */\n mediaPosition?: string;\n /** Overlay recipe: subtle, cinematic, or stronger text-safe contrast. */\n mediaTreatment?: string;\n /** Where this template's copy sits, so the scrim is shaped to the type\n * instead of to the frame. Defaults to \"full\" (scrim both the middle and\n * the lower third) for templates that have not declared an anchor. */\n textAnchor?: MediaTextAnchor;\n /** Background motion effect (drift / pulse / Ken Burns). Applied to the photo/video. */\n backgroundEffect?: string;\n /** Stable seed for the gradient breathing animation. Pass the scene's\n * text content (or any stable string) — it's hashed deterministically. */\n seed?: number | string;\n /** Pause video when preview is paused. Defaults to true (export path). */\n isPlaying?: boolean;\n beatIntensity?: number;\n}\n\nexport const SceneBackground: React.FC<SceneBackgroundProps> = ({\n style,\n progress,\n sceneDuration,\n width: _width, // accepted for symmetry; not currently used in render\n height: _height,\n mediaUrl = \"\",\n mediaType = \"auto\",\n mediaPoster,\n mediaPosition = \"center\",\n mediaTreatment = \"cinematic\",\n textAnchor = \"full\",\n backgroundEffect,\n seed,\n isPlaying = true,\n beatIntensity = 0,\n}) => {\n void _width;\n void _height;\n const resolved = resolveMediaType(mediaType, mediaUrl);\n const wantsMedia = resolved !== \"gradient\" && !!mediaUrl;\n const externalVideoBackdrop = useExternalVideoBackdrop();\n const hasExternalVideoBackdrop = externalVideoBackdrop !== false && resolved === \"video\";\n const externalVideoFailed = externalVideoBackdrop === \"fallback\" && resolved === \"video\";\n const externalVideoReady = externalVideoBackdrop === \"ready\" && resolved === \"video\";\n\n // A scrim exists to hold type against footage. Until the footage is on\n // screen there is nothing to hold it against, so the scrim would just be\n // darkening the brand gradient it was never meant to touch — the scene\n // reads as a muddy, vignetted version of the gradient scenes beside it.\n // That window is not rare: it covers the whole load, and it never ends for\n // a dead URL, a blocked host, or an empty stock search.\n //\n // So the media has to paint before anything darkens for it. Both edges of\n // the swap land on the same commit — scrim and picture appear together,\n // and the fallback is the clean gradient the docs always promised.\n const [mediaPaint, setMediaPaint] = useState<MediaPaintState>(() =>\n initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster),\n );\n useEffect(() => {\n setMediaPaint(initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster));\n // Video reports its own paint through onLoadedData / onError below.\n if (!wantsMedia || resolved !== \"photo\") return;\n if (typeof Image === \"undefined\") return;\n let cancelled = false;\n const probe = new Image();\n probe.onload = () => {\n if (!cancelled) setMediaPaint(\"ready\");\n };\n probe.onerror = () => {\n if (!cancelled) setMediaPaint(\"failed\");\n };\n probe.src = mediaUrl;\n if (probe.complete) setMediaPaint(probe.naturalWidth > 0 ? \"ready\" : \"failed\");\n return () => {\n cancelled = true;\n probe.onload = null;\n probe.onerror = null;\n };\n }, [mediaUrl, mediaPoster, resolved, wantsMedia]);\n\n // The element stays mounted while pending — that is what loads it. Only a\n // confirmed failure takes it back out.\n const showMedia = wantsMedia && mediaPaint !== \"failed\";\n const showTreatment = wantsMedia && mediaPaint === \"ready\";\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const resolvedTreatment = resolveMediaTreatment(mediaTreatment);\n const treatmentLayers = getMediaTreatmentLayers(resolvedTreatment, textAnchor);\n\n const gradSeed =\n typeof seed === \"number\"\n ? seed\n : typeof seed === \"string\"\n ? seed.split(\"\").reduce((acc, c) => acc + c.charCodeAt(0), 0)\n : 0;\n\n const bgTransform = getBackgroundTransform(\n backgroundEffect,\n progress,\n beatIntensity,\n );\n\n return (\n <>\n {(!hasExternalVideoBackdrop || externalVideoFailed) && (\n <BrandGradientOverlay\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n seed={gradSeed}\n />\n )}\n\n {showMedia && !hasExternalVideoBackdrop &&\n (resolved === \"video\" ? (\n <SceneVideoBackdrop\n mediaUrl={mediaUrl}\n mediaPoster={mediaPoster}\n mediaPosition={mediaPosition}\n backgroundEffect={backgroundEffect}\n progress={progress}\n beatIntensity={beatIntensity}\n isPlaying={isPlaying}\n onReady={() => setMediaPaint(\"ready\")}\n onError={() => setMediaPaint(\"failed\")}\n />\n ) : (\n <div\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n backgroundImage: `url(${mediaUrl})`,\n backgroundSize: \"cover\",\n backgroundPosition: resolvedPosition,\n }}\n />\n ))}\n\n {(hasExternalVideoBackdrop ? externalVideoReady : showTreatment) && !externalVideoFailed &&\n treatmentLayers.map((layer) => (\n <div\n key={layer.id}\n data-media-treatment={resolvedTreatment}\n data-media-overlay={layer.id}\n style={{\n position: \"absolute\",\n inset: 0,\n background: layer.background,\n pointerEvents: \"none\",\n ...layer.style,\n }}\n />\n ))}\n </>\n );\n};\n"
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"path": "src/visual-system/primitives/devices/TerminalOutput.tsx",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"path": "src/visual-system/scene-templates/social-testimonial.tsx",
|
|
20
20
|
"type": "registry:component",
|
|
21
21
|
"target": "vanillasky/scene-templates/social-testimonial.tsx",
|
|
22
|
-
"content": "/**\n * social-testimonial — animated quote card with word-by-word text reveal.\n *\n * Converted from Remotion TestimonialCard pattern. Dark card with large\n * quotation mark, word-by-word quote reveal, horizontal divider, and\n * author avatar + name section. No TextOverlay — text is integral to the visual.\n *\n * Block structure (docs/blocks.md):\n * background — SceneBackground + accent radial glow behind the card\n * hero — TestimonialCard primitive (quote mark, word-by-word quote,\n * divider, author row; text integral — no caption slot)\n */\nimport type { SceneTemplateProps } from \"./types\";\nimport { resolveTokens } from \"../theme\";\nimport { withOpacity } from \"../theme\";\nimport { SceneBackground, getMediaBackgroundProps } from \"./scene-background\";\nimport { stripPipe } from \"../typography\";\nimport { TestimonialCard } from \"../primitives/social/TestimonialCard\";\n\nexport const SocialTestimonialTemplate: React.FC<SceneTemplateProps> = ({\n variables,\n style,\n progress,\n beatIntensity,\n width,\n height,\n sceneDuration,\n isPlaying = true,\n}) => {\n const dim = Math.min(width, height);\n const { primary, foreground, font, surfaceElevated, muted } = resolveTokens(style);\n\n const quote = stripPipe(String(variables.quote || \"\"));\n const gradSeed = quote.split(\"\").reduce((acc: number, c: string) => acc + c.charCodeAt(0), 0);\n const authorName = String(variables.authorName || \"Jessica Torres\");\n const authorRole = String(variables.authorRole || \"\");\n const avatarBg = primary;\n\n return (\n <div\n style={{\n width,\n height,\n backgroundColor: \"#000\",\n position: \"relative\",\n overflow: \"hidden\",\n fontFamily: font,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n }}\n >\n {/* [slot: background] Gradient / media backdrop */}\n <SceneBackground\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n width={width}\n height={height}\n {...getMediaBackgroundProps(variables)}\n seed={gradSeed}\n isPlaying={isPlaying}\n beatIntensity={beatIntensity}\n />\n {/* [slot: background] Glow behind card — large radial gradient (replaces filter: blur) */}\n <div\n style={{\n position: \"absolute\",\n width: dim * 0.9,\n height: dim * 0.7,\n borderRadius: \"50%\",\n background: `radial-gradient(ellipse, ${withOpacity(primary, 0.15)} 0%, ${withOpacity(primary, 0.05)} 40%, transparent 70%)`,\n top: \"50%\",\n left: \"50%\",\n transform: \"translate(-50%, -50%)\",\n pointerEvents: \"none\",\n }}\n />\n\n {/* [slot: hero] Quote card — shared primitive */}\n <TestimonialCard\n progress={progress}\n quote={quote}\n authorName={authorName}\n authorRole={authorRole}\n avatarColor={avatarBg}\n accent={primary}\n surfaceElevated={surfaceElevated}\n foreground={foreground}\n muted={muted}\n width={width}\n height={height}\n beatIntensity={beatIntensity}\n />\n </div>\n );\n};\n"
|
|
22
|
+
"content": "/**\n * social-testimonial — animated quote card with word-by-word text reveal.\n *\n * Converted from Remotion TestimonialCard pattern. Dark card with large\n * quotation mark, word-by-word quote reveal, horizontal divider, and\n * author avatar + name section. No TextOverlay — text is integral to the visual.\n *\n * Block structure (docs/blocks.md):\n * background — SceneBackground + accent radial glow behind the card\n * hero — TestimonialCard primitive (quote mark, word-by-word quote,\n * divider, author row; text integral — no caption slot)\n */\nimport type { SceneTemplateProps } from \"./types\";\nimport { resolveTokens } from \"../theme\";\nimport { withOpacity } from \"../theme\";\nimport { SceneBackground, getMediaBackgroundProps } from \"./scene-background\";\nimport { stripPipe } from \"../typography\";\nimport { TestimonialCard } from \"../primitives/social/TestimonialCard\";\n\nexport const SocialTestimonialTemplate: React.FC<SceneTemplateProps> = ({\n variables,\n style,\n progress,\n beatIntensity,\n width,\n height,\n sceneDuration,\n isPlaying = true,\n}) => {\n const dim = Math.min(width, height);\n const { primary, foreground, font, surfaceElevated, muted } = resolveTokens(style);\n\n const quote = stripPipe(String(variables.quote || \"\"));\n const gradSeed = quote.split(\"\").reduce((acc: number, c: string) => acc + c.charCodeAt(0), 0);\n const authorName = String(variables.authorName || \"Jessica Torres\");\n const authorRole = String(variables.authorRole || \"\");\n const avatarBg = primary;\n\n return (\n <div\n style={{\n width,\n height,\n backgroundColor: \"var(--vanillasky-template-surface, #000)\",\n position: \"relative\",\n overflow: \"hidden\",\n fontFamily: font,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n }}\n >\n {/* [slot: background] Gradient / media backdrop */}\n <SceneBackground\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n width={width}\n height={height}\n {...getMediaBackgroundProps(variables)}\n seed={gradSeed}\n isPlaying={isPlaying}\n beatIntensity={beatIntensity}\n />\n {/* [slot: background] Glow behind card — large radial gradient (replaces filter: blur) */}\n <div\n style={{\n position: \"absolute\",\n width: dim * 0.9,\n height: dim * 0.7,\n borderRadius: \"50%\",\n background: `radial-gradient(ellipse, ${withOpacity(primary, 0.15)} 0%, ${withOpacity(primary, 0.05)} 40%, transparent 70%)`,\n top: \"50%\",\n left: \"50%\",\n transform: \"translate(-50%, -50%)\",\n pointerEvents: \"none\",\n }}\n />\n\n {/* [slot: hero] Quote card — shared primitive */}\n <TestimonialCard\n progress={progress}\n quote={quote}\n authorName={authorName}\n authorRole={authorRole}\n avatarColor={avatarBg}\n accent={primary}\n surfaceElevated={surfaceElevated}\n foreground={foreground}\n muted={muted}\n width={width}\n height={height}\n beatIntensity={beatIntensity}\n />\n </div>\n );\n};\n"
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
"path": "src/visual-system/primitives/social/TestimonialCard.tsx",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"path": "src/visual-system/scene-templates/scene-background.tsx",
|
|
38
38
|
"type": "registry:component",
|
|
39
39
|
"target": "vanillasky/scene-templates/scene-background.tsx",
|
|
40
|
-
"content": "/**\n * SceneBackground — shared backdrop component for any scene template that\n * wants to support both a brand-color gradient and stock media (Pexels\n * photo / video) as an alternate atmosphere.\n *\n * Usage:\n * <SceneBackground\n * style={style}\n * progress={progress}\n * sceneDuration={sceneDuration}\n * width={width}\n * height={height}\n * mediaUrl={String(variables.mediaUrl || \"\")}\n * mediaType={String(variables.mediaType || \"auto\")}\n * seed={String(variables.texts || \"\")}\n * isPlaying={isPlaying}\n * />\n * ... template's content layered on top\n *\n * Behavior:\n * - Brand gradient is the always-on backdrop (uses BrandGradientOverlay).\n * - When mediaUrl is set and mediaType isn't \"gradient\", the photo/video\n * covers the gradient. Legibility is then split between two instruments:\n * eased scrims shaped to where the template's copy sits (`textAnchor`),\n * and a per-glyph halo on the type itself (MEDIA_TEXT_SHADOW). Neither\n * alone can hold white type over a blown-out highlight without flattening\n * the picture; together they do it at roughly half the darkening.\n * - mediaType=\"gradient\" deliberately ignores mediaUrl and renders only\n * the brand gradient. First-class atmospheric mode.\n * - When mediaUrl is empty, 404s, is blocked, or Pexels search returned\n * nothing, the gradient shows through cleanly and no scrim is painted —\n * a scrim over a bare gradient is just a muddy gradient. Enforced, not\n * assumed: the media has to load before anything darkens for it.\n *\n * Extracted from bg-media.tsx so any template can compose it. bg-media\n * now uses this component too — its \"media is the scene\" identity comes\n * from how it positions the title (centered, full-frame), not from\n * duplicated render logic.\n */\n\nimport React, { useEffect, useRef, useState } from \"react\";\nimport {\n hasSceneMedia,\n limitsConcurrentVideoDecoders,\n resolveMediaType,\n type ResolvedMediaType,\n} from \"./media-source\";\nimport type { TemplateStyle } from \"../template-context\";\nimport { BrandGradientOverlay } from \"../backgrounds\";\nimport { getBackgroundTransform } from \"../backgrounds\";\n\nexport { hasSceneMedia, resolveMediaType };\nexport type { ResolvedMediaType };\n\nexport type MediaPosition = \"center\" | \"top\" | \"bottom\" | \"left\" | \"right\";\nexport type MediaTreatment = \"subtle\" | \"cinematic\" | \"text-safe\";\n\nconst MEDIA_POSITIONS: Record<MediaPosition, string> = {\n center: \"center center\",\n top: \"center top\",\n bottom: \"center bottom\",\n left: \"left center\",\n right: \"right center\",\n};\n\nexport function resolveMediaPosition(value: string): string {\n return MEDIA_POSITIONS[value as MediaPosition] ?? MEDIA_POSITIONS.center;\n}\n\nexport function resolveMediaTreatment(value: string): MediaTreatment {\n return value === \"subtle\" || value === \"text-safe\" ? value : \"cinematic\";\n}\n\nexport interface MediaTreatmentLayer {\n id: \"vignette\" | \"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 const vignette: MediaTreatmentLayer = {\n id: \"vignette\",\n background:\n treatment === \"subtle\"\n ? `radial-gradient(ellipse at center, ${easedStops(0.28, 45, 100, \"fade-in\")})`\n : `radial-gradient(ellipse at center, ${easedStops(0.72, 32, 100, \"fade-in\")})`,\n };\n if (treatment === \"subtle\") return [vignette];\n\n const textSafe = treatment === \"text-safe\";\n const layers: MediaTreatmentLayer[] = [vignette];\n\n if (anchor !== \"bottom\") {\n layers.push({\n id: \"center-scrim\",\n background: textSafe\n ? `radial-gradient(ellipse 92% 58% at 50% 50%, ${easedStops(0.46, 34, 90, \"fade-out\")})`\n : `radial-gradient(ellipse 88% 52% at 50% 50%, ${easedStops(0.26, 30, 88, \"fade-out\")})`,\n });\n }\n\n if (anchor !== \"center\") {\n layers.push({\n id: \"bottom-scrim\",\n background: `linear-gradient(to top, ${easedStops(textSafe ? 0.64 : 0.5, 8, 100, \"fade-out\")})`,\n style: { top: \"55%\" },\n });\n }\n\n return layers;\n}\n\n/**\n * Whether the backdrop is actually painting, which is what decides if a scrim\n * is earned. \"pending\" is a browser-only state: static and export renders\n * never run effects and never wait on a network, so they start (and stay)\n * ready and their output is unchanged.\n */\ntype MediaPaintState = \"pending\" | \"ready\" | \"failed\";\n\nfunction initialMediaPaint(\n wantsMedia: boolean,\n resolved: ResolvedMediaType,\n mediaUrl: string,\n mediaPoster: string | undefined,\n): MediaPaintState {\n if (typeof window === \"undefined\") return \"ready\";\n if (!wantsMedia) return \"ready\";\n // A poster paints the video's frame immediately, so the scene is already\n // showing footage even though the stream is still decoding.\n if (resolved === \"video\") return mediaPoster ? \"ready\" : \"pending\";\n if (typeof Image === \"undefined\") return \"ready\";\n // Preloaded or browser-cached media decodes synchronously. Reporting it\n // ready on the first render keeps the common mid-playback case free of a\n // gradient-then-photo flicker.\n const cached = new Image();\n cached.src = mediaUrl;\n return cached.complete && cached.naturalWidth > 0 ? \"ready\" : \"pending\";\n}\n\nexport function getMediaBackgroundProps(variables: Record<string, unknown>) {\n return {\n mediaUrl: String(variables.mediaUrl || \"\"),\n mediaType: String(variables.mediaType || \"auto\"),\n mediaPoster: String(variables.mediaPoster || \"\"),\n mediaPosition: String(variables.mediaPosition || \"center\"),\n mediaTreatment: String(variables.mediaTreatment || \"cinematic\"),\n };\n}\n\nexport interface SceneBackgroundProps {\n style: TemplateStyle;\n progress: number;\n sceneDuration?: number;\n width: number;\n height: number;\n mediaUrl?: string;\n mediaType?: string;\n /** Still image URL shown while the <video> backdrop decodes its first\n * frame. Without it the element renders transparent during the\n * ~50–400ms decode window and the gradient flashes through. */\n mediaPoster?: string;\n /** Cover-crop focal anchor. Keeps the important edge/subject visible. */\n mediaPosition?: string;\n /** Overlay recipe: subtle, cinematic, or stronger text-safe contrast. */\n mediaTreatment?: string;\n /** Where this template's copy sits, so the scrim is shaped to the type\n * instead of to the frame. Defaults to \"full\" (scrim both the middle and\n * the lower third) for templates that have not declared an anchor. */\n textAnchor?: MediaTextAnchor;\n /** Background motion effect (drift / pulse / Ken Burns). Applied to the photo/video. */\n backgroundEffect?: string;\n /** Stable seed for the gradient breathing animation. Pass the scene's\n * text content (or any stable string) — it's hashed deterministically. */\n seed?: number | string;\n /** Pause video when preview is paused. Defaults to true (export path). */\n isPlaying?: boolean;\n beatIntensity?: number;\n}\n\nexport const SceneBackground: React.FC<SceneBackgroundProps> = ({\n style,\n progress,\n sceneDuration,\n width: _width, // accepted for symmetry; not currently used in render\n height: _height,\n mediaUrl = \"\",\n mediaType = \"auto\",\n mediaPoster,\n mediaPosition = \"center\",\n mediaTreatment = \"cinematic\",\n textAnchor = \"full\",\n backgroundEffect,\n seed,\n isPlaying = true,\n beatIntensity = 0,\n}) => {\n void _width;\n void _height;\n const resolved = resolveMediaType(mediaType, mediaUrl);\n const wantsMedia = resolved !== \"gradient\" && !!mediaUrl;\n\n // A scrim exists to hold type against footage. Until the footage is on\n // screen there is nothing to hold it against, so the scrim would just be\n // darkening the brand gradient it was never meant to touch — the scene\n // reads as a muddy, vignetted version of the gradient scenes beside it.\n // That window is not rare: it covers the whole load, and it never ends for\n // a dead URL, a blocked host, or an empty stock search.\n //\n // So the media has to paint before anything darkens for it. Both edges of\n // the swap land on the same commit — scrim and picture appear together,\n // and the fallback is the clean gradient the docs always promised.\n const [mediaPaint, setMediaPaint] = useState<MediaPaintState>(() =>\n initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster),\n );\n // A stock-video poster is useful only until this exact <video> can paint its\n // own first frame. Desktop incoming scenes decode while opacity is zero, so\n // loadeddata is sufficient there. Mobile WebKit cannot mount that incoming\n // decoder safely; loadeddata can precede the first presented frame and\n // removing the poster in that gap exposes the brand gradient.\n const [decodedVideoUrl, setDecodedVideoUrl] = useState<string>();\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 // Video playback control — same pause/seek logic bg-media used pre-extract.\n const videoRef = useRef<HTMLVideoElement>(null);\n const videoStarted = useRef(false);\n\n useEffect(() => {\n const vid = videoRef.current;\n if (!vid) return;\n if (!isPlaying) {\n vid.pause();\n videoStarted.current = false;\n return;\n }\n if (!videoStarted.current) {\n vid.playbackRate = 1;\n // A fresh element is already at zero. Avoid seeking it before metadata\n // exists; only a resumed scene needs rewinding.\n if (vid.currentTime > 0) vid.currentTime = 0;\n vid.play().catch(() => {});\n videoStarted.current = true;\n }\n }, [progress, isPlaying]);\n\n // Release the decoder on unmount. Without this, iOS Safari keeps the\n // video's decoder buffer alive after the React node is gone — each\n // scene transition (or play/pause/play cycle that remounts the active\n // scene) leaks one decoder, eventually crossing the renderer's memory\n // ceiling and triggering \"A problem repeatedly occurred.\" Same recipe\n // as #409's CanvasPreview preload cleanup: pause → clear src → load().\n // Capture the ref at mount-time so the cleanup uses the same node we\n // mounted (the ref's .current is stale by unmount).\n useEffect(() => {\n const vid = videoRef.current;\n return () => {\n if (!vid) return;\n vid.pause();\n vid.removeAttribute(\"src\");\n vid.load();\n };\n }, []);\n\n return (\n <>\n <BrandGradientOverlay\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n seed={gradSeed}\n />\n\n {showMedia &&\n (resolved === \"video\" ? (\n <video\n ref={videoRef}\n src={mediaUrl}\n // Poster paints during the decode window so the user sees the\n // (still) first frame instead of a transparent <video> letting\n // the brand gradient show through. Pexels returns a thumbnail\n // image alongside each video; fillPexelsUrls stores it in\n // `variables.mediaPoster`. Layered defense alongside preload=\"auto\"\n // below: on desktop the byte preloader makes decode fast, on\n // mobile (where the preloader skips video pre-mounting to dodge\n // the iOS Safari memory crash) the poster is the primary shield.\n poster={decodedVideoUrl === mediaUrl ? undefined : mediaPoster || undefined}\n muted\n loop\n playsInline\n // preload=\"auto\" — without it, browsers default to \"metadata\":\n // they only load the container/dimensions, not the byte stream\n // needed to decode frames. The element then renders transparent\n // until the first decoded frame arrives, letting the brand\n // gradient flash through whenever a scene mid-playback transitions\n // to a media backdrop. The parent preloader caches the bytes, but\n // decoder state is per-element, so the active mount still has to\n // decode the first frame; \"auto\" kicks that work off the instant\n // the element mounts.\n preload=\"auto\"\n onLoadedData={(event) => {\n setMediaPaint(\"ready\");\n const video = event.currentTarget;\n if (!limitsConcurrentVideoDecoders()) {\n setDecodedVideoUrl(mediaUrl);\n return;\n }\n // requestVideoFrameCallback fires only after WebKit submits an\n // actual frame to the compositor. Until then the poster stays\n // in place; browsers without the API safely keep the poster\n // attribute and replace it themselves when playback begins.\n video.requestVideoFrameCallback?.(() => {\n if (video.isConnected) setDecodedVideoUrl(mediaUrl);\n });\n }}\n onError={() => setMediaPaint(\"failed\")}\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: resolvedPosition,\n // Mobile WebKit can briefly make a newly mounted video layer\n // transparent while handing the decoder over from the previous\n // scene. Keep the already-warmed poster painted behind the\n // replaced content so that gap never exposes the gradient.\n backgroundImage: mediaPoster ? `url(${mediaPoster})` : undefined,\n backgroundSize: \"cover\",\n backgroundPosition: resolvedPosition,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n }}\n />\n ) : (\n <div\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n backgroundImage: `url(${mediaUrl})`,\n backgroundSize: \"cover\",\n backgroundPosition: resolvedPosition,\n }}\n />\n ))}\n\n {showTreatment &&\n treatmentLayers.map((layer) => (\n <div\n key={layer.id}\n data-media-treatment={resolvedTreatment}\n data-media-overlay={layer.id}\n style={{\n position: \"absolute\",\n inset: 0,\n background: layer.background,\n pointerEvents: \"none\",\n ...layer.style,\n }}\n />\n ))}\n </>\n );\n};\n"
|
|
40
|
+
"content": "/**\n * SceneBackground — shared backdrop component for any scene template that\n * wants to support both a brand-color gradient and stock media (Pexels\n * photo / video) as an alternate atmosphere.\n *\n * Usage:\n * <SceneBackground\n * style={style}\n * progress={progress}\n * sceneDuration={sceneDuration}\n * width={width}\n * height={height}\n * mediaUrl={String(variables.mediaUrl || \"\")}\n * mediaType={String(variables.mediaType || \"auto\")}\n * seed={String(variables.texts || \"\")}\n * isPlaying={isPlaying}\n * />\n * ... template's content layered on top\n *\n * Behavior:\n * - Brand gradient is the always-on backdrop (uses BrandGradientOverlay).\n * - When mediaUrl is set and mediaType isn't \"gradient\", the photo/video\n * covers the gradient. Legibility is then split between two instruments:\n * eased scrims shaped to where the template's copy sits (`textAnchor`),\n * and a per-glyph halo on the type itself (MEDIA_TEXT_SHADOW). Neither\n * alone can hold white type over a blown-out highlight without flattening\n * the picture; together they do it at roughly half the darkening.\n * - mediaType=\"gradient\" deliberately ignores mediaUrl and renders only\n * the brand gradient. First-class atmospheric mode.\n * - When mediaUrl is empty, 404s, is blocked, or Pexels search returned\n * nothing, the gradient shows through cleanly and no scrim is painted —\n * a scrim over a bare gradient is just a muddy gradient. Enforced, not\n * assumed: the media has to load before anything darkens for it.\n *\n * Extracted from bg-media.tsx so any template can compose it. bg-media\n * now uses this component too — its \"media is the scene\" identity comes\n * from how it positions the title (centered, full-frame), not from\n * duplicated render logic.\n */\n\nimport React, { useEffect, useState } from \"react\";\nimport {\n hasSceneMedia,\n resolveMediaType,\n type ResolvedMediaType,\n} from \"./media-source\";\nimport type { TemplateStyle } from \"../template-context\";\nimport { BrandGradientOverlay } from \"../backgrounds\";\nimport { getBackgroundTransform } from \"../backgrounds\";\nimport { useExternalVideoBackdrop } from \"./external-video-backdrop\";\nimport { resolveMediaPosition, type MediaPosition } from \"./media-position\";\nimport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\n\nexport { hasSceneMedia, resolveMediaType };\nexport { resolveMediaPosition } from \"./media-position\";\nexport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\nexport type { ResolvedMediaType };\n\nexport type { MediaPosition };\nexport type MediaTreatment = \"subtle\" | \"cinematic\" | \"text-safe\";\n\nexport function resolveMediaTreatment(value: string): MediaTreatment {\n return 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 const vignette: MediaTreatmentLayer = {\n id: \"vignette\",\n background:\n treatment === \"subtle\"\n ? `radial-gradient(ellipse at center, ${easedStops(0.28, 45, 100, \"fade-in\")})`\n : `radial-gradient(ellipse at center, ${easedStops(0.72, 32, 100, \"fade-in\")})`,\n };\n if (treatment === \"subtle\") return [vignette];\n\n const textSafe = treatment === \"text-safe\";\n const layers: MediaTreatmentLayer[] = [vignette];\n\n if (anchor !== \"bottom\") {\n layers.push({\n id: \"center-scrim\",\n background: textSafe\n ? `radial-gradient(ellipse 92% 58% at 50% 50%, ${easedStops(0.46, 34, 90, \"fade-out\")})`\n : `radial-gradient(ellipse 88% 52% at 50% 50%, ${easedStops(0.26, 30, 88, \"fade-out\")})`,\n });\n }\n\n if (anchor !== \"center\") {\n layers.push({\n id: \"bottom-scrim\",\n background: `linear-gradient(to top, ${easedStops(textSafe ? 0.64 : 0.5, 8, 100, \"fade-out\")})`,\n style: { top: \"55%\" },\n });\n }\n\n return layers;\n}\n\n/**\n * Whether the backdrop is actually painting, which is what decides if a scrim\n * is earned. \"pending\" is a browser-only state: static and export renders\n * never run effects and never wait on a network, so they start (and stay)\n * ready and their output is unchanged.\n */\ntype MediaPaintState = \"pending\" | \"ready\" | \"failed\";\n\nfunction initialMediaPaint(\n wantsMedia: boolean,\n resolved: ResolvedMediaType,\n mediaUrl: string,\n mediaPoster: string | undefined,\n): MediaPaintState {\n if (typeof window === \"undefined\") return \"ready\";\n if (!wantsMedia) return \"ready\";\n // A poster paints the video's frame immediately, so the scene is already\n // showing footage even though the stream is still decoding.\n if (resolved === \"video\") return mediaPoster ? \"ready\" : \"pending\";\n if (typeof Image === \"undefined\") return \"ready\";\n // Preloaded or browser-cached media decodes synchronously. Reporting it\n // ready on the first render keeps the common mid-playback case free of a\n // gradient-then-photo flicker.\n const cached = new Image();\n cached.src = mediaUrl;\n return cached.complete && cached.naturalWidth > 0 ? \"ready\" : \"pending\";\n}\n\nexport function getMediaBackgroundProps(variables: Record<string, unknown>) {\n return {\n mediaUrl: String(variables.mediaUrl || \"\"),\n mediaType: String(variables.mediaType || \"auto\"),\n mediaPoster: String(variables.mediaPoster || \"\"),\n mediaPosition: String(variables.mediaPosition || \"center\"),\n mediaTreatment: String(variables.mediaTreatment || \"cinematic\"),\n };\n}\n\nexport interface SceneBackgroundProps {\n style: TemplateStyle;\n progress: number;\n sceneDuration?: number;\n width: number;\n height: number;\n mediaUrl?: string;\n mediaType?: string;\n /** Still image URL shown while the <video> backdrop decodes its first\n * frame. Without it the element renders transparent during the\n * ~50–400ms decode window and the gradient flashes through. */\n mediaPoster?: string;\n /** Cover-crop focal anchor. Keeps the important edge/subject visible. */\n mediaPosition?: string;\n /** Overlay recipe: subtle, cinematic, or stronger text-safe contrast. */\n mediaTreatment?: string;\n /** Where this template's copy sits, so the scrim is shaped to the type\n * instead of to the frame. Defaults to \"full\" (scrim both the middle and\n * the lower third) for templates that have not declared an anchor. */\n textAnchor?: MediaTextAnchor;\n /** Background motion effect (drift / pulse / Ken Burns). Applied to the photo/video. */\n backgroundEffect?: string;\n /** Stable seed for the gradient breathing animation. Pass the scene's\n * text content (or any stable string) — it's hashed deterministically. */\n seed?: number | string;\n /** Pause video when preview is paused. Defaults to true (export path). */\n isPlaying?: boolean;\n beatIntensity?: number;\n}\n\nexport const SceneBackground: React.FC<SceneBackgroundProps> = ({\n style,\n progress,\n sceneDuration,\n width: _width, // accepted for symmetry; not currently used in render\n height: _height,\n mediaUrl = \"\",\n mediaType = \"auto\",\n mediaPoster,\n mediaPosition = \"center\",\n mediaTreatment = \"cinematic\",\n textAnchor = \"full\",\n backgroundEffect,\n seed,\n isPlaying = true,\n beatIntensity = 0,\n}) => {\n void _width;\n void _height;\n const resolved = resolveMediaType(mediaType, mediaUrl);\n const wantsMedia = resolved !== \"gradient\" && !!mediaUrl;\n const externalVideoBackdrop = useExternalVideoBackdrop();\n const hasExternalVideoBackdrop = externalVideoBackdrop !== false && resolved === \"video\";\n const externalVideoFailed = externalVideoBackdrop === \"fallback\" && resolved === \"video\";\n const externalVideoReady = externalVideoBackdrop === \"ready\" && resolved === \"video\";\n\n // A scrim exists to hold type against footage. Until the footage is on\n // screen there is nothing to hold it against, so the scrim would just be\n // darkening the brand gradient it was never meant to touch — the scene\n // reads as a muddy, vignetted version of the gradient scenes beside it.\n // That window is not rare: it covers the whole load, and it never ends for\n // a dead URL, a blocked host, or an empty stock search.\n //\n // So the media has to paint before anything darkens for it. Both edges of\n // the swap land on the same commit — scrim and picture appear together,\n // and the fallback is the clean gradient the docs always promised.\n const [mediaPaint, setMediaPaint] = useState<MediaPaintState>(() =>\n initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster),\n );\n useEffect(() => {\n setMediaPaint(initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster));\n // Video reports its own paint through onLoadedData / onError below.\n if (!wantsMedia || resolved !== \"photo\") return;\n if (typeof Image === \"undefined\") return;\n let cancelled = false;\n const probe = new Image();\n probe.onload = () => {\n if (!cancelled) setMediaPaint(\"ready\");\n };\n probe.onerror = () => {\n if (!cancelled) setMediaPaint(\"failed\");\n };\n probe.src = mediaUrl;\n if (probe.complete) setMediaPaint(probe.naturalWidth > 0 ? \"ready\" : \"failed\");\n return () => {\n cancelled = true;\n probe.onload = null;\n probe.onerror = null;\n };\n }, [mediaUrl, mediaPoster, resolved, wantsMedia]);\n\n // The element stays mounted while pending — that is what loads it. Only a\n // confirmed failure takes it back out.\n const showMedia = wantsMedia && mediaPaint !== \"failed\";\n const showTreatment = wantsMedia && mediaPaint === \"ready\";\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const resolvedTreatment = resolveMediaTreatment(mediaTreatment);\n const treatmentLayers = getMediaTreatmentLayers(resolvedTreatment, textAnchor);\n\n const gradSeed =\n typeof seed === \"number\"\n ? seed\n : typeof seed === \"string\"\n ? seed.split(\"\").reduce((acc, c) => acc + c.charCodeAt(0), 0)\n : 0;\n\n const bgTransform = getBackgroundTransform(\n backgroundEffect,\n progress,\n beatIntensity,\n );\n\n return (\n <>\n {(!hasExternalVideoBackdrop || externalVideoFailed) && (\n <BrandGradientOverlay\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n seed={gradSeed}\n />\n )}\n\n {showMedia && !hasExternalVideoBackdrop &&\n (resolved === \"video\" ? (\n <SceneVideoBackdrop\n mediaUrl={mediaUrl}\n mediaPoster={mediaPoster}\n mediaPosition={mediaPosition}\n backgroundEffect={backgroundEffect}\n progress={progress}\n beatIntensity={beatIntensity}\n isPlaying={isPlaying}\n onReady={() => setMediaPaint(\"ready\")}\n onError={() => setMediaPaint(\"failed\")}\n />\n ) : (\n <div\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n backgroundImage: `url(${mediaUrl})`,\n backgroundSize: \"cover\",\n backgroundPosition: resolvedPosition,\n }}\n />\n ))}\n\n {(hasExternalVideoBackdrop ? externalVideoReady : showTreatment) && !externalVideoFailed &&\n treatmentLayers.map((layer) => (\n <div\n key={layer.id}\n data-media-treatment={resolvedTreatment}\n data-media-overlay={layer.id}\n style={{\n position: \"absolute\",\n inset: 0,\n background: layer.background,\n pointerEvents: \"none\",\n ...layer.style,\n }}\n />\n ))}\n </>\n );\n};\n"
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
43
|
"path": "src/visual-system/scene-templates/types.ts",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"path": "src/visual-system/scene-templates/infographic-stat-row.tsx",
|
|
20
20
|
"type": "registry:component",
|
|
21
21
|
"target": "vanillasky/scene-templates/infographic-stat-row.tsx",
|
|
22
|
-
"content": "/**\n * infographic-stat-row — 3 big stats side by side (portrait: stacked).\n *\n * Each stat has a large number, a short label below, and an optional\n * prefix/unit. Numbers count up from 0. Stats stagger in with bouncy\n * spring. TextOverlay title on top.\n */\n\nimport React from \"react\";\nimport type { SceneTemplateProps } from \"./types\";\nimport { resolveTokens } from \"../theme\";\nimport { TemplateText } from \"./template-text\";\nimport { StatBadgeRow } from \"../primitives/typography/StatBadgeRow\";\nimport { SceneBackground, getMediaBackgroundProps, hasSceneMedia } from \"./scene-background\";\n\nexport const InfographicStatRowTemplate: React.FC<SceneTemplateProps> = ({\n variables,\n style,\n progress,\n motionProgress = progress,\n beatIntensity,\n width,\n height,\n safeZone,\n sceneDuration,\n backgroundEffect,\n isPlaying = true,\n}) => {\n const { foreground, font } = resolveTokens(style);\n const textColor = foreground;\n const textsRaw = String(variables.texts || \"\");\n\n const stats = [\n { value: String(variables.stat1Value || \"\"), label: String(variables.stat1Label || \"\") },\n { value: String(variables.stat2Value || \"\"), label: String(variables.stat2Label || \"\") },\n { value: String(variables.stat3Value || \"\"), label: String(variables.stat3Label || \"\") },\n ];\n\n return (\n <div\n style={{\n width,\n height,\n backgroundColor: \"#000\",\n position: \"relative\",\n overflow: \"hidden\",\n fontFamily: font,\n }}\n >\n {/* [slot: background] */}\n <SceneBackground\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n width={width}\n height={height}\n {...getMediaBackgroundProps(variables)}\n backgroundEffect={backgroundEffect}\n seed={textsRaw}\n isPlaying={isPlaying}\n beatIntensity={beatIntensity}\n />\n\n {/* [slot: caption] */}\n <TemplateText\n overMedia={hasSceneMedia(variables)}\n motionProgress={motionProgress}\n typeTreatment={resolveTokens(style).preset.type}\n archetype=\"subtle\"\n text={String(variables.texts ?? \"\")}\n progress={progress}\n sceneDuration={sceneDuration ?? 3}\n width={width}\n height={height}\n position=\"top\"\n sizeRole=\"headline\"\n safeZone={safeZone}\n font={font}\n color={textColor}\n beatIntensity={beatIntensity}\n />\n\n {/* [slot: hero] */}\n <StatBadgeRow\n progress={motionProgress}\n stats={stats}\n width={width}\n height={height}\n font={font}\n textColor={textColor}\n />\n </div>\n );\n};\n"
|
|
22
|
+
"content": "/**\n * infographic-stat-row — 3 big stats side by side (portrait: stacked).\n *\n * Each stat has a large number, a short label below, and an optional\n * prefix/unit. Numbers count up from 0. Stats stagger in with bouncy\n * spring. TextOverlay title on top.\n */\n\nimport React from \"react\";\nimport type { SceneTemplateProps } from \"./types\";\nimport { resolveTokens } from \"../theme\";\nimport { TemplateText } from \"./template-text\";\nimport { StatBadgeRow } from \"../primitives/typography/StatBadgeRow\";\nimport { SceneBackground, getMediaBackgroundProps, hasSceneMedia } from \"./scene-background\";\n\nexport const InfographicStatRowTemplate: React.FC<SceneTemplateProps> = ({\n variables,\n style,\n progress,\n motionProgress = progress,\n beatIntensity,\n width,\n height,\n safeZone,\n sceneDuration,\n backgroundEffect,\n isPlaying = true,\n}) => {\n const { foreground, font } = resolveTokens(style);\n const textColor = foreground;\n const textsRaw = String(variables.texts || \"\");\n\n const stats = [\n { value: String(variables.stat1Value || \"\"), label: String(variables.stat1Label || \"\") },\n { value: String(variables.stat2Value || \"\"), label: String(variables.stat2Label || \"\") },\n { value: String(variables.stat3Value || \"\"), label: String(variables.stat3Label || \"\") },\n ];\n\n return (\n <div\n style={{\n width,\n height,\n backgroundColor: \"var(--vanillasky-template-surface, #000)\",\n position: \"relative\",\n overflow: \"hidden\",\n fontFamily: font,\n }}\n >\n {/* [slot: background] */}\n <SceneBackground\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n width={width}\n height={height}\n {...getMediaBackgroundProps(variables)}\n backgroundEffect={backgroundEffect}\n seed={textsRaw}\n isPlaying={isPlaying}\n beatIntensity={beatIntensity}\n />\n\n {/* [slot: caption] */}\n <TemplateText\n overMedia={hasSceneMedia(variables)}\n motionProgress={motionProgress}\n typeTreatment={resolveTokens(style).preset.type}\n archetype=\"subtle\"\n text={String(variables.texts ?? \"\")}\n progress={progress}\n sceneDuration={sceneDuration ?? 3}\n width={width}\n height={height}\n position=\"top\"\n sizeRole=\"headline\"\n safeZone={safeZone}\n font={font}\n color={textColor}\n beatIntensity={beatIntensity}\n />\n\n {/* [slot: hero] */}\n <StatBadgeRow\n progress={motionProgress}\n stats={stats}\n width={width}\n height={height}\n font={font}\n textColor={textColor}\n />\n </div>\n );\n};\n"
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
"path": "src/visual-system/scene-templates/media-source.ts",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"path": "src/visual-system/scene-templates/scene-background.tsx",
|
|
32
32
|
"type": "registry:component",
|
|
33
33
|
"target": "vanillasky/scene-templates/scene-background.tsx",
|
|
34
|
-
"content": "/**\n * SceneBackground — shared backdrop component for any scene template that\n * wants to support both a brand-color gradient and stock media (Pexels\n * photo / video) as an alternate atmosphere.\n *\n * Usage:\n * <SceneBackground\n * style={style}\n * progress={progress}\n * sceneDuration={sceneDuration}\n * width={width}\n * height={height}\n * mediaUrl={String(variables.mediaUrl || \"\")}\n * mediaType={String(variables.mediaType || \"auto\")}\n * seed={String(variables.texts || \"\")}\n * isPlaying={isPlaying}\n * />\n * ... template's content layered on top\n *\n * Behavior:\n * - Brand gradient is the always-on backdrop (uses BrandGradientOverlay).\n * - When mediaUrl is set and mediaType isn't \"gradient\", the photo/video\n * covers the gradient. Legibility is then split between two instruments:\n * eased scrims shaped to where the template's copy sits (`textAnchor`),\n * and a per-glyph halo on the type itself (MEDIA_TEXT_SHADOW). Neither\n * alone can hold white type over a blown-out highlight without flattening\n * the picture; together they do it at roughly half the darkening.\n * - mediaType=\"gradient\" deliberately ignores mediaUrl and renders only\n * the brand gradient. First-class atmospheric mode.\n * - When mediaUrl is empty, 404s, is blocked, or Pexels search returned\n * nothing, the gradient shows through cleanly and no scrim is painted —\n * a scrim over a bare gradient is just a muddy gradient. Enforced, not\n * assumed: the media has to load before anything darkens for it.\n *\n * Extracted from bg-media.tsx so any template can compose it. bg-media\n * now uses this component too — its \"media is the scene\" identity comes\n * from how it positions the title (centered, full-frame), not from\n * duplicated render logic.\n */\n\nimport React, { useEffect, useRef, useState } from \"react\";\nimport {\n hasSceneMedia,\n limitsConcurrentVideoDecoders,\n resolveMediaType,\n type ResolvedMediaType,\n} from \"./media-source\";\nimport type { TemplateStyle } from \"../template-context\";\nimport { BrandGradientOverlay } from \"../backgrounds\";\nimport { getBackgroundTransform } from \"../backgrounds\";\n\nexport { hasSceneMedia, resolveMediaType };\nexport type { ResolvedMediaType };\n\nexport type MediaPosition = \"center\" | \"top\" | \"bottom\" | \"left\" | \"right\";\nexport type MediaTreatment = \"subtle\" | \"cinematic\" | \"text-safe\";\n\nconst MEDIA_POSITIONS: Record<MediaPosition, string> = {\n center: \"center center\",\n top: \"center top\",\n bottom: \"center bottom\",\n left: \"left center\",\n right: \"right center\",\n};\n\nexport function resolveMediaPosition(value: string): string {\n return MEDIA_POSITIONS[value as MediaPosition] ?? MEDIA_POSITIONS.center;\n}\n\nexport function resolveMediaTreatment(value: string): MediaTreatment {\n return value === \"subtle\" || value === \"text-safe\" ? value : \"cinematic\";\n}\n\nexport interface MediaTreatmentLayer {\n id: \"vignette\" | \"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 const vignette: MediaTreatmentLayer = {\n id: \"vignette\",\n background:\n treatment === \"subtle\"\n ? `radial-gradient(ellipse at center, ${easedStops(0.28, 45, 100, \"fade-in\")})`\n : `radial-gradient(ellipse at center, ${easedStops(0.72, 32, 100, \"fade-in\")})`,\n };\n if (treatment === \"subtle\") return [vignette];\n\n const textSafe = treatment === \"text-safe\";\n const layers: MediaTreatmentLayer[] = [vignette];\n\n if (anchor !== \"bottom\") {\n layers.push({\n id: \"center-scrim\",\n background: textSafe\n ? `radial-gradient(ellipse 92% 58% at 50% 50%, ${easedStops(0.46, 34, 90, \"fade-out\")})`\n : `radial-gradient(ellipse 88% 52% at 50% 50%, ${easedStops(0.26, 30, 88, \"fade-out\")})`,\n });\n }\n\n if (anchor !== \"center\") {\n layers.push({\n id: \"bottom-scrim\",\n background: `linear-gradient(to top, ${easedStops(textSafe ? 0.64 : 0.5, 8, 100, \"fade-out\")})`,\n style: { top: \"55%\" },\n });\n }\n\n return layers;\n}\n\n/**\n * Whether the backdrop is actually painting, which is what decides if a scrim\n * is earned. \"pending\" is a browser-only state: static and export renders\n * never run effects and never wait on a network, so they start (and stay)\n * ready and their output is unchanged.\n */\ntype MediaPaintState = \"pending\" | \"ready\" | \"failed\";\n\nfunction initialMediaPaint(\n wantsMedia: boolean,\n resolved: ResolvedMediaType,\n mediaUrl: string,\n mediaPoster: string | undefined,\n): MediaPaintState {\n if (typeof window === \"undefined\") return \"ready\";\n if (!wantsMedia) return \"ready\";\n // A poster paints the video's frame immediately, so the scene is already\n // showing footage even though the stream is still decoding.\n if (resolved === \"video\") return mediaPoster ? \"ready\" : \"pending\";\n if (typeof Image === \"undefined\") return \"ready\";\n // Preloaded or browser-cached media decodes synchronously. Reporting it\n // ready on the first render keeps the common mid-playback case free of a\n // gradient-then-photo flicker.\n const cached = new Image();\n cached.src = mediaUrl;\n return cached.complete && cached.naturalWidth > 0 ? \"ready\" : \"pending\";\n}\n\nexport function getMediaBackgroundProps(variables: Record<string, unknown>) {\n return {\n mediaUrl: String(variables.mediaUrl || \"\"),\n mediaType: String(variables.mediaType || \"auto\"),\n mediaPoster: String(variables.mediaPoster || \"\"),\n mediaPosition: String(variables.mediaPosition || \"center\"),\n mediaTreatment: String(variables.mediaTreatment || \"cinematic\"),\n };\n}\n\nexport interface SceneBackgroundProps {\n style: TemplateStyle;\n progress: number;\n sceneDuration?: number;\n width: number;\n height: number;\n mediaUrl?: string;\n mediaType?: string;\n /** Still image URL shown while the <video> backdrop decodes its first\n * frame. Without it the element renders transparent during the\n * ~50–400ms decode window and the gradient flashes through. */\n mediaPoster?: string;\n /** Cover-crop focal anchor. Keeps the important edge/subject visible. */\n mediaPosition?: string;\n /** Overlay recipe: subtle, cinematic, or stronger text-safe contrast. */\n mediaTreatment?: string;\n /** Where this template's copy sits, so the scrim is shaped to the type\n * instead of to the frame. Defaults to \"full\" (scrim both the middle and\n * the lower third) for templates that have not declared an anchor. */\n textAnchor?: MediaTextAnchor;\n /** Background motion effect (drift / pulse / Ken Burns). Applied to the photo/video. */\n backgroundEffect?: string;\n /** Stable seed for the gradient breathing animation. Pass the scene's\n * text content (or any stable string) — it's hashed deterministically. */\n seed?: number | string;\n /** Pause video when preview is paused. Defaults to true (export path). */\n isPlaying?: boolean;\n beatIntensity?: number;\n}\n\nexport const SceneBackground: React.FC<SceneBackgroundProps> = ({\n style,\n progress,\n sceneDuration,\n width: _width, // accepted for symmetry; not currently used in render\n height: _height,\n mediaUrl = \"\",\n mediaType = \"auto\",\n mediaPoster,\n mediaPosition = \"center\",\n mediaTreatment = \"cinematic\",\n textAnchor = \"full\",\n backgroundEffect,\n seed,\n isPlaying = true,\n beatIntensity = 0,\n}) => {\n void _width;\n void _height;\n const resolved = resolveMediaType(mediaType, mediaUrl);\n const wantsMedia = resolved !== \"gradient\" && !!mediaUrl;\n\n // A scrim exists to hold type against footage. Until the footage is on\n // screen there is nothing to hold it against, so the scrim would just be\n // darkening the brand gradient it was never meant to touch — the scene\n // reads as a muddy, vignetted version of the gradient scenes beside it.\n // That window is not rare: it covers the whole load, and it never ends for\n // a dead URL, a blocked host, or an empty stock search.\n //\n // So the media has to paint before anything darkens for it. Both edges of\n // the swap land on the same commit — scrim and picture appear together,\n // and the fallback is the clean gradient the docs always promised.\n const [mediaPaint, setMediaPaint] = useState<MediaPaintState>(() =>\n initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster),\n );\n // A stock-video poster is useful only until this exact <video> can paint its\n // own first frame. Desktop incoming scenes decode while opacity is zero, so\n // loadeddata is sufficient there. Mobile WebKit cannot mount that incoming\n // decoder safely; loadeddata can precede the first presented frame and\n // removing the poster in that gap exposes the brand gradient.\n const [decodedVideoUrl, setDecodedVideoUrl] = useState<string>();\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 // Video playback control — same pause/seek logic bg-media used pre-extract.\n const videoRef = useRef<HTMLVideoElement>(null);\n const videoStarted = useRef(false);\n\n useEffect(() => {\n const vid = videoRef.current;\n if (!vid) return;\n if (!isPlaying) {\n vid.pause();\n videoStarted.current = false;\n return;\n }\n if (!videoStarted.current) {\n vid.playbackRate = 1;\n // A fresh element is already at zero. Avoid seeking it before metadata\n // exists; only a resumed scene needs rewinding.\n if (vid.currentTime > 0) vid.currentTime = 0;\n vid.play().catch(() => {});\n videoStarted.current = true;\n }\n }, [progress, isPlaying]);\n\n // Release the decoder on unmount. Without this, iOS Safari keeps the\n // video's decoder buffer alive after the React node is gone — each\n // scene transition (or play/pause/play cycle that remounts the active\n // scene) leaks one decoder, eventually crossing the renderer's memory\n // ceiling and triggering \"A problem repeatedly occurred.\" Same recipe\n // as #409's CanvasPreview preload cleanup: pause → clear src → load().\n // Capture the ref at mount-time so the cleanup uses the same node we\n // mounted (the ref's .current is stale by unmount).\n useEffect(() => {\n const vid = videoRef.current;\n return () => {\n if (!vid) return;\n vid.pause();\n vid.removeAttribute(\"src\");\n vid.load();\n };\n }, []);\n\n return (\n <>\n <BrandGradientOverlay\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n seed={gradSeed}\n />\n\n {showMedia &&\n (resolved === \"video\" ? (\n <video\n ref={videoRef}\n src={mediaUrl}\n // Poster paints during the decode window so the user sees the\n // (still) first frame instead of a transparent <video> letting\n // the brand gradient show through. Pexels returns a thumbnail\n // image alongside each video; fillPexelsUrls stores it in\n // `variables.mediaPoster`. Layered defense alongside preload=\"auto\"\n // below: on desktop the byte preloader makes decode fast, on\n // mobile (where the preloader skips video pre-mounting to dodge\n // the iOS Safari memory crash) the poster is the primary shield.\n poster={decodedVideoUrl === mediaUrl ? undefined : mediaPoster || undefined}\n muted\n loop\n playsInline\n // preload=\"auto\" — without it, browsers default to \"metadata\":\n // they only load the container/dimensions, not the byte stream\n // needed to decode frames. The element then renders transparent\n // until the first decoded frame arrives, letting the brand\n // gradient flash through whenever a scene mid-playback transitions\n // to a media backdrop. The parent preloader caches the bytes, but\n // decoder state is per-element, so the active mount still has to\n // decode the first frame; \"auto\" kicks that work off the instant\n // the element mounts.\n preload=\"auto\"\n onLoadedData={(event) => {\n setMediaPaint(\"ready\");\n const video = event.currentTarget;\n if (!limitsConcurrentVideoDecoders()) {\n setDecodedVideoUrl(mediaUrl);\n return;\n }\n // requestVideoFrameCallback fires only after WebKit submits an\n // actual frame to the compositor. Until then the poster stays\n // in place; browsers without the API safely keep the poster\n // attribute and replace it themselves when playback begins.\n video.requestVideoFrameCallback?.(() => {\n if (video.isConnected) setDecodedVideoUrl(mediaUrl);\n });\n }}\n onError={() => setMediaPaint(\"failed\")}\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: resolvedPosition,\n // Mobile WebKit can briefly make a newly mounted video layer\n // transparent while handing the decoder over from the previous\n // scene. Keep the already-warmed poster painted behind the\n // replaced content so that gap never exposes the gradient.\n backgroundImage: mediaPoster ? `url(${mediaPoster})` : undefined,\n backgroundSize: \"cover\",\n backgroundPosition: resolvedPosition,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n }}\n />\n ) : (\n <div\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n backgroundImage: `url(${mediaUrl})`,\n backgroundSize: \"cover\",\n backgroundPosition: resolvedPosition,\n }}\n />\n ))}\n\n {showTreatment &&\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"
|
|
34
|
+
"content": "/**\n * SceneBackground — shared backdrop component for any scene template that\n * wants to support both a brand-color gradient and stock media (Pexels\n * photo / video) as an alternate atmosphere.\n *\n * Usage:\n * <SceneBackground\n * style={style}\n * progress={progress}\n * sceneDuration={sceneDuration}\n * width={width}\n * height={height}\n * mediaUrl={String(variables.mediaUrl || \"\")}\n * mediaType={String(variables.mediaType || \"auto\")}\n * seed={String(variables.texts || \"\")}\n * isPlaying={isPlaying}\n * />\n * ... template's content layered on top\n *\n * Behavior:\n * - Brand gradient is the always-on backdrop (uses BrandGradientOverlay).\n * - When mediaUrl is set and mediaType isn't \"gradient\", the photo/video\n * covers the gradient. Legibility is then split between two instruments:\n * eased scrims shaped to where the template's copy sits (`textAnchor`),\n * and a per-glyph halo on the type itself (MEDIA_TEXT_SHADOW). Neither\n * alone can hold white type over a blown-out highlight without flattening\n * the picture; together they do it at roughly half the darkening.\n * - mediaType=\"gradient\" deliberately ignores mediaUrl and renders only\n * the brand gradient. First-class atmospheric mode.\n * - When mediaUrl is empty, 404s, is blocked, or Pexels search returned\n * nothing, the gradient shows through cleanly and no scrim is painted —\n * a scrim over a bare gradient is just a muddy gradient. Enforced, not\n * assumed: the media has to load before anything darkens for it.\n *\n * Extracted from bg-media.tsx so any template can compose it. bg-media\n * now uses this component too — its \"media is the scene\" identity comes\n * from how it positions the title (centered, full-frame), not from\n * duplicated render logic.\n */\n\nimport React, { useEffect, useState } from \"react\";\nimport {\n hasSceneMedia,\n resolveMediaType,\n type ResolvedMediaType,\n} from \"./media-source\";\nimport type { TemplateStyle } from \"../template-context\";\nimport { BrandGradientOverlay } from \"../backgrounds\";\nimport { getBackgroundTransform } from \"../backgrounds\";\nimport { useExternalVideoBackdrop } from \"./external-video-backdrop\";\nimport { resolveMediaPosition, type MediaPosition } from \"./media-position\";\nimport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\n\nexport { hasSceneMedia, resolveMediaType };\nexport { resolveMediaPosition } from \"./media-position\";\nexport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\nexport type { ResolvedMediaType };\n\nexport type { MediaPosition };\nexport type MediaTreatment = \"subtle\" | \"cinematic\" | \"text-safe\";\n\nexport function resolveMediaTreatment(value: string): MediaTreatment {\n return 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 const vignette: MediaTreatmentLayer = {\n id: \"vignette\",\n background:\n treatment === \"subtle\"\n ? `radial-gradient(ellipse at center, ${easedStops(0.28, 45, 100, \"fade-in\")})`\n : `radial-gradient(ellipse at center, ${easedStops(0.72, 32, 100, \"fade-in\")})`,\n };\n if (treatment === \"subtle\") return [vignette];\n\n const textSafe = treatment === \"text-safe\";\n const layers: MediaTreatmentLayer[] = [vignette];\n\n if (anchor !== \"bottom\") {\n layers.push({\n id: \"center-scrim\",\n background: textSafe\n ? `radial-gradient(ellipse 92% 58% at 50% 50%, ${easedStops(0.46, 34, 90, \"fade-out\")})`\n : `radial-gradient(ellipse 88% 52% at 50% 50%, ${easedStops(0.26, 30, 88, \"fade-out\")})`,\n });\n }\n\n if (anchor !== \"center\") {\n layers.push({\n id: \"bottom-scrim\",\n background: `linear-gradient(to top, ${easedStops(textSafe ? 0.64 : 0.5, 8, 100, \"fade-out\")})`,\n style: { top: \"55%\" },\n });\n }\n\n return layers;\n}\n\n/**\n * Whether the backdrop is actually painting, which is what decides if a scrim\n * is earned. \"pending\" is a browser-only state: static and export renders\n * never run effects and never wait on a network, so they start (and stay)\n * ready and their output is unchanged.\n */\ntype MediaPaintState = \"pending\" | \"ready\" | \"failed\";\n\nfunction initialMediaPaint(\n wantsMedia: boolean,\n resolved: ResolvedMediaType,\n mediaUrl: string,\n mediaPoster: string | undefined,\n): MediaPaintState {\n if (typeof window === \"undefined\") return \"ready\";\n if (!wantsMedia) return \"ready\";\n // A poster paints the video's frame immediately, so the scene is already\n // showing footage even though the stream is still decoding.\n if (resolved === \"video\") return mediaPoster ? \"ready\" : \"pending\";\n if (typeof Image === \"undefined\") return \"ready\";\n // Preloaded or browser-cached media decodes synchronously. Reporting it\n // ready on the first render keeps the common mid-playback case free of a\n // gradient-then-photo flicker.\n const cached = new Image();\n cached.src = mediaUrl;\n return cached.complete && cached.naturalWidth > 0 ? \"ready\" : \"pending\";\n}\n\nexport function getMediaBackgroundProps(variables: Record<string, unknown>) {\n return {\n mediaUrl: String(variables.mediaUrl || \"\"),\n mediaType: String(variables.mediaType || \"auto\"),\n mediaPoster: String(variables.mediaPoster || \"\"),\n mediaPosition: String(variables.mediaPosition || \"center\"),\n mediaTreatment: String(variables.mediaTreatment || \"cinematic\"),\n };\n}\n\nexport interface SceneBackgroundProps {\n style: TemplateStyle;\n progress: number;\n sceneDuration?: number;\n width: number;\n height: number;\n mediaUrl?: string;\n mediaType?: string;\n /** Still image URL shown while the <video> backdrop decodes its first\n * frame. Without it the element renders transparent during the\n * ~50–400ms decode window and the gradient flashes through. */\n mediaPoster?: string;\n /** Cover-crop focal anchor. Keeps the important edge/subject visible. */\n mediaPosition?: string;\n /** Overlay recipe: subtle, cinematic, or stronger text-safe contrast. */\n mediaTreatment?: string;\n /** Where this template's copy sits, so the scrim is shaped to the type\n * instead of to the frame. Defaults to \"full\" (scrim both the middle and\n * the lower third) for templates that have not declared an anchor. */\n textAnchor?: MediaTextAnchor;\n /** Background motion effect (drift / pulse / Ken Burns). Applied to the photo/video. */\n backgroundEffect?: string;\n /** Stable seed for the gradient breathing animation. Pass the scene's\n * text content (or any stable string) — it's hashed deterministically. */\n seed?: number | string;\n /** Pause video when preview is paused. Defaults to true (export path). */\n isPlaying?: boolean;\n beatIntensity?: number;\n}\n\nexport const SceneBackground: React.FC<SceneBackgroundProps> = ({\n style,\n progress,\n sceneDuration,\n width: _width, // accepted for symmetry; not currently used in render\n height: _height,\n mediaUrl = \"\",\n mediaType = \"auto\",\n mediaPoster,\n mediaPosition = \"center\",\n mediaTreatment = \"cinematic\",\n textAnchor = \"full\",\n backgroundEffect,\n seed,\n isPlaying = true,\n beatIntensity = 0,\n}) => {\n void _width;\n void _height;\n const resolved = resolveMediaType(mediaType, mediaUrl);\n const wantsMedia = resolved !== \"gradient\" && !!mediaUrl;\n const externalVideoBackdrop = useExternalVideoBackdrop();\n const hasExternalVideoBackdrop = externalVideoBackdrop !== false && resolved === \"video\";\n const externalVideoFailed = externalVideoBackdrop === \"fallback\" && resolved === \"video\";\n const externalVideoReady = externalVideoBackdrop === \"ready\" && resolved === \"video\";\n\n // A scrim exists to hold type against footage. Until the footage is on\n // screen there is nothing to hold it against, so the scrim would just be\n // darkening the brand gradient it was never meant to touch — the scene\n // reads as a muddy, vignetted version of the gradient scenes beside it.\n // That window is not rare: it covers the whole load, and it never ends for\n // a dead URL, a blocked host, or an empty stock search.\n //\n // So the media has to paint before anything darkens for it. Both edges of\n // the swap land on the same commit — scrim and picture appear together,\n // and the fallback is the clean gradient the docs always promised.\n const [mediaPaint, setMediaPaint] = useState<MediaPaintState>(() =>\n initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster),\n );\n useEffect(() => {\n setMediaPaint(initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster));\n // Video reports its own paint through onLoadedData / onError below.\n if (!wantsMedia || resolved !== \"photo\") return;\n if (typeof Image === \"undefined\") return;\n let cancelled = false;\n const probe = new Image();\n probe.onload = () => {\n if (!cancelled) setMediaPaint(\"ready\");\n };\n probe.onerror = () => {\n if (!cancelled) setMediaPaint(\"failed\");\n };\n probe.src = mediaUrl;\n if (probe.complete) setMediaPaint(probe.naturalWidth > 0 ? \"ready\" : \"failed\");\n return () => {\n cancelled = true;\n probe.onload = null;\n probe.onerror = null;\n };\n }, [mediaUrl, mediaPoster, resolved, wantsMedia]);\n\n // The element stays mounted while pending — that is what loads it. Only a\n // confirmed failure takes it back out.\n const showMedia = wantsMedia && mediaPaint !== \"failed\";\n const showTreatment = wantsMedia && mediaPaint === \"ready\";\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const resolvedTreatment = resolveMediaTreatment(mediaTreatment);\n const treatmentLayers = getMediaTreatmentLayers(resolvedTreatment, textAnchor);\n\n const gradSeed =\n typeof seed === \"number\"\n ? seed\n : typeof seed === \"string\"\n ? seed.split(\"\").reduce((acc, c) => acc + c.charCodeAt(0), 0)\n : 0;\n\n const bgTransform = getBackgroundTransform(\n backgroundEffect,\n progress,\n beatIntensity,\n );\n\n return (\n <>\n {(!hasExternalVideoBackdrop || externalVideoFailed) && (\n <BrandGradientOverlay\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n seed={gradSeed}\n />\n )}\n\n {showMedia && !hasExternalVideoBackdrop &&\n (resolved === \"video\" ? (\n <SceneVideoBackdrop\n mediaUrl={mediaUrl}\n mediaPoster={mediaPoster}\n mediaPosition={mediaPosition}\n backgroundEffect={backgroundEffect}\n progress={progress}\n beatIntensity={beatIntensity}\n isPlaying={isPlaying}\n onReady={() => setMediaPaint(\"ready\")}\n onError={() => setMediaPaint(\"failed\")}\n />\n ) : (\n <div\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n backgroundImage: `url(${mediaUrl})`,\n backgroundSize: \"cover\",\n backgroundPosition: resolvedPosition,\n }}\n />\n ))}\n\n {(hasExternalVideoBackdrop ? externalVideoReady : showTreatment) && !externalVideoFailed &&\n treatmentLayers.map((layer) => (\n <div\n key={layer.id}\n data-media-treatment={resolvedTreatment}\n data-media-overlay={layer.id}\n style={{\n position: \"absolute\",\n inset: 0,\n background: layer.background,\n pointerEvents: \"none\",\n ...layer.style,\n }}\n />\n ))}\n </>\n );\n};\n"
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"path": "src/visual-system/primitives/typography/StatBadgeRow.tsx",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"path": "src/visual-system/scene-templates/social-tweet.tsx",
|
|
20
20
|
"type": "registry:component",
|
|
21
21
|
"target": "vanillasky/scene-templates/social-tweet.tsx",
|
|
22
|
-
"content": "/**\n * social-tweet — X-style post card on a media background.\n *\n * Card visuals + motion live in the TweetCard primitive\n * (src/visual-system/primitives/social/TweetCard.tsx). This template wires the\n * scene background + variable schema + primitive together.\n */\n\nimport * as React from \"react\";\nimport type { SceneTemplateProps } from \"./types\";\nimport { resolveTokens } from \"../theme\";\nimport { SceneBackground, getMediaBackgroundProps } from \"./scene-background\";\nimport { TweetCard } from \"../primitives/social/TweetCard\";\n\nexport const SocialTweetTemplate: React.FC<SceneTemplateProps> = ({\n variables,\n style,\n progress,\n motionProgress = progress,\n beatIntensity,\n width,\n height,\n sceneDuration,\n isPlaying = true,\n backgroundEffect,\n}) => {\n const authorName = String(variables.authorName || \"Your brand\");\n const message = String(variables.message || \"\");\n const { primary } = resolveTokens(style);\n const seed = (authorName + message).split(\"\").reduce((acc, c) => acc + c.charCodeAt(0), 0);\n\n /* Boolean field with a defensive string coercion for untyped model output. */\n const verifiedRaw = variables.authorVerified;\n const isVerified = verifiedRaw === true || String(verifiedRaw ?? \"\").toLowerCase() === \"true\";\n\n return (\n <div\n style={{\n width,\n height,\n backgroundColor: \"#000\",\n position: \"relative\",\n overflow: \"hidden\",\n fontFamily: \"'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif\",\n }}\n >\n {/* [slot: background] Gradient / media backdrop */}\n <SceneBackground\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n width={width}\n height={height}\n {...getMediaBackgroundProps(variables)}\n backgroundEffect={backgroundEffect}\n seed={seed}\n isPlaying={isPlaying}\n beatIntensity={beatIntensity}\n />\n\n {/* [slot: hero] Tweet card — TweetCard primitive (post text is the\n content; no separate caption slot) */}\n <TweetCard\n progress={progress}\n motionProgress={motionProgress}\n authorName={authorName}\n authorHandle={String(variables.authorHandle || \"\")}\n authorVerified={isVerified}\n message={message}\n targetLikes={Number(variables.likes) || 0}\n targetReplies={Number(variables.replies) || 0}\n accent={primary}\n width={width}\n height={height}\n beatIntensity={beatIntensity}\n />\n </div>\n );\n};\n"
|
|
22
|
+
"content": "/**\n * social-tweet — X-style post card on a media background.\n *\n * Card visuals + motion live in the TweetCard primitive\n * (src/visual-system/primitives/social/TweetCard.tsx). This template wires the\n * scene background + variable schema + primitive together.\n */\n\nimport * as React from \"react\";\nimport type { SceneTemplateProps } from \"./types\";\nimport { resolveTokens } from \"../theme\";\nimport { SceneBackground, getMediaBackgroundProps } from \"./scene-background\";\nimport { TweetCard } from \"../primitives/social/TweetCard\";\n\nexport const SocialTweetTemplate: React.FC<SceneTemplateProps> = ({\n variables,\n style,\n progress,\n motionProgress = progress,\n beatIntensity,\n width,\n height,\n sceneDuration,\n isPlaying = true,\n backgroundEffect,\n}) => {\n const authorName = String(variables.authorName || \"Your brand\");\n const message = String(variables.message || \"\");\n const { primary } = resolveTokens(style);\n const seed = (authorName + message).split(\"\").reduce((acc, c) => acc + c.charCodeAt(0), 0);\n\n /* Boolean field with a defensive string coercion for untyped model output. */\n const verifiedRaw = variables.authorVerified;\n const isVerified = verifiedRaw === true || String(verifiedRaw ?? \"\").toLowerCase() === \"true\";\n\n return (\n <div\n style={{\n width,\n height,\n backgroundColor: \"var(--vanillasky-template-surface, #000)\",\n position: \"relative\",\n overflow: \"hidden\",\n fontFamily: \"'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif\",\n }}\n >\n {/* [slot: background] Gradient / media backdrop */}\n <SceneBackground\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n width={width}\n height={height}\n {...getMediaBackgroundProps(variables)}\n backgroundEffect={backgroundEffect}\n seed={seed}\n isPlaying={isPlaying}\n beatIntensity={beatIntensity}\n />\n\n {/* [slot: hero] Tweet card — TweetCard primitive (post text is the\n content; no separate caption slot) */}\n <TweetCard\n progress={progress}\n motionProgress={motionProgress}\n authorName={authorName}\n authorHandle={String(variables.authorHandle || \"\")}\n authorVerified={isVerified}\n message={message}\n targetLikes={Number(variables.likes) || 0}\n targetReplies={Number(variables.replies) || 0}\n accent={primary}\n width={width}\n height={height}\n beatIntensity={beatIntensity}\n />\n </div>\n );\n};\n"
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
"path": "src/visual-system/primitives/social/TweetCard.tsx",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"path": "src/visual-system/scene-templates/scene-background.tsx",
|
|
50
50
|
"type": "registry:component",
|
|
51
51
|
"target": "vanillasky/scene-templates/scene-background.tsx",
|
|
52
|
-
"content": "/**\n * SceneBackground — shared backdrop component for any scene template that\n * wants to support both a brand-color gradient and stock media (Pexels\n * photo / video) as an alternate atmosphere.\n *\n * Usage:\n * <SceneBackground\n * style={style}\n * progress={progress}\n * sceneDuration={sceneDuration}\n * width={width}\n * height={height}\n * mediaUrl={String(variables.mediaUrl || \"\")}\n * mediaType={String(variables.mediaType || \"auto\")}\n * seed={String(variables.texts || \"\")}\n * isPlaying={isPlaying}\n * />\n * ... template's content layered on top\n *\n * Behavior:\n * - Brand gradient is the always-on backdrop (uses BrandGradientOverlay).\n * - When mediaUrl is set and mediaType isn't \"gradient\", the photo/video\n * covers the gradient. Legibility is then split between two instruments:\n * eased scrims shaped to where the template's copy sits (`textAnchor`),\n * and a per-glyph halo on the type itself (MEDIA_TEXT_SHADOW). Neither\n * alone can hold white type over a blown-out highlight without flattening\n * the picture; together they do it at roughly half the darkening.\n * - mediaType=\"gradient\" deliberately ignores mediaUrl and renders only\n * the brand gradient. First-class atmospheric mode.\n * - When mediaUrl is empty, 404s, is blocked, or Pexels search returned\n * nothing, the gradient shows through cleanly and no scrim is painted —\n * a scrim over a bare gradient is just a muddy gradient. Enforced, not\n * assumed: the media has to load before anything darkens for it.\n *\n * Extracted from bg-media.tsx so any template can compose it. bg-media\n * now uses this component too — its \"media is the scene\" identity comes\n * from how it positions the title (centered, full-frame), not from\n * duplicated render logic.\n */\n\nimport React, { useEffect, useRef, useState } from \"react\";\nimport {\n hasSceneMedia,\n limitsConcurrentVideoDecoders,\n resolveMediaType,\n type ResolvedMediaType,\n} from \"./media-source\";\nimport type { TemplateStyle } from \"../template-context\";\nimport { BrandGradientOverlay } from \"../backgrounds\";\nimport { getBackgroundTransform } from \"../backgrounds\";\n\nexport { hasSceneMedia, resolveMediaType };\nexport type { ResolvedMediaType };\n\nexport type MediaPosition = \"center\" | \"top\" | \"bottom\" | \"left\" | \"right\";\nexport type MediaTreatment = \"subtle\" | \"cinematic\" | \"text-safe\";\n\nconst MEDIA_POSITIONS: Record<MediaPosition, string> = {\n center: \"center center\",\n top: \"center top\",\n bottom: \"center bottom\",\n left: \"left center\",\n right: \"right center\",\n};\n\nexport function resolveMediaPosition(value: string): string {\n return MEDIA_POSITIONS[value as MediaPosition] ?? MEDIA_POSITIONS.center;\n}\n\nexport function resolveMediaTreatment(value: string): MediaTreatment {\n return value === \"subtle\" || value === \"text-safe\" ? value : \"cinematic\";\n}\n\nexport interface MediaTreatmentLayer {\n id: \"vignette\" | \"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 const vignette: MediaTreatmentLayer = {\n id: \"vignette\",\n background:\n treatment === \"subtle\"\n ? `radial-gradient(ellipse at center, ${easedStops(0.28, 45, 100, \"fade-in\")})`\n : `radial-gradient(ellipse at center, ${easedStops(0.72, 32, 100, \"fade-in\")})`,\n };\n if (treatment === \"subtle\") return [vignette];\n\n const textSafe = treatment === \"text-safe\";\n const layers: MediaTreatmentLayer[] = [vignette];\n\n if (anchor !== \"bottom\") {\n layers.push({\n id: \"center-scrim\",\n background: textSafe\n ? `radial-gradient(ellipse 92% 58% at 50% 50%, ${easedStops(0.46, 34, 90, \"fade-out\")})`\n : `radial-gradient(ellipse 88% 52% at 50% 50%, ${easedStops(0.26, 30, 88, \"fade-out\")})`,\n });\n }\n\n if (anchor !== \"center\") {\n layers.push({\n id: \"bottom-scrim\",\n background: `linear-gradient(to top, ${easedStops(textSafe ? 0.64 : 0.5, 8, 100, \"fade-out\")})`,\n style: { top: \"55%\" },\n });\n }\n\n return layers;\n}\n\n/**\n * Whether the backdrop is actually painting, which is what decides if a scrim\n * is earned. \"pending\" is a browser-only state: static and export renders\n * never run effects and never wait on a network, so they start (and stay)\n * ready and their output is unchanged.\n */\ntype MediaPaintState = \"pending\" | \"ready\" | \"failed\";\n\nfunction initialMediaPaint(\n wantsMedia: boolean,\n resolved: ResolvedMediaType,\n mediaUrl: string,\n mediaPoster: string | undefined,\n): MediaPaintState {\n if (typeof window === \"undefined\") return \"ready\";\n if (!wantsMedia) return \"ready\";\n // A poster paints the video's frame immediately, so the scene is already\n // showing footage even though the stream is still decoding.\n if (resolved === \"video\") return mediaPoster ? \"ready\" : \"pending\";\n if (typeof Image === \"undefined\") return \"ready\";\n // Preloaded or browser-cached media decodes synchronously. Reporting it\n // ready on the first render keeps the common mid-playback case free of a\n // gradient-then-photo flicker.\n const cached = new Image();\n cached.src = mediaUrl;\n return cached.complete && cached.naturalWidth > 0 ? \"ready\" : \"pending\";\n}\n\nexport function getMediaBackgroundProps(variables: Record<string, unknown>) {\n return {\n mediaUrl: String(variables.mediaUrl || \"\"),\n mediaType: String(variables.mediaType || \"auto\"),\n mediaPoster: String(variables.mediaPoster || \"\"),\n mediaPosition: String(variables.mediaPosition || \"center\"),\n mediaTreatment: String(variables.mediaTreatment || \"cinematic\"),\n };\n}\n\nexport interface SceneBackgroundProps {\n style: TemplateStyle;\n progress: number;\n sceneDuration?: number;\n width: number;\n height: number;\n mediaUrl?: string;\n mediaType?: string;\n /** Still image URL shown while the <video> backdrop decodes its first\n * frame. Without it the element renders transparent during the\n * ~50–400ms decode window and the gradient flashes through. */\n mediaPoster?: string;\n /** Cover-crop focal anchor. Keeps the important edge/subject visible. */\n mediaPosition?: string;\n /** Overlay recipe: subtle, cinematic, or stronger text-safe contrast. */\n mediaTreatment?: string;\n /** Where this template's copy sits, so the scrim is shaped to the type\n * instead of to the frame. Defaults to \"full\" (scrim both the middle and\n * the lower third) for templates that have not declared an anchor. */\n textAnchor?: MediaTextAnchor;\n /** Background motion effect (drift / pulse / Ken Burns). Applied to the photo/video. */\n backgroundEffect?: string;\n /** Stable seed for the gradient breathing animation. Pass the scene's\n * text content (or any stable string) — it's hashed deterministically. */\n seed?: number | string;\n /** Pause video when preview is paused. Defaults to true (export path). */\n isPlaying?: boolean;\n beatIntensity?: number;\n}\n\nexport const SceneBackground: React.FC<SceneBackgroundProps> = ({\n style,\n progress,\n sceneDuration,\n width: _width, // accepted for symmetry; not currently used in render\n height: _height,\n mediaUrl = \"\",\n mediaType = \"auto\",\n mediaPoster,\n mediaPosition = \"center\",\n mediaTreatment = \"cinematic\",\n textAnchor = \"full\",\n backgroundEffect,\n seed,\n isPlaying = true,\n beatIntensity = 0,\n}) => {\n void _width;\n void _height;\n const resolved = resolveMediaType(mediaType, mediaUrl);\n const wantsMedia = resolved !== \"gradient\" && !!mediaUrl;\n\n // A scrim exists to hold type against footage. Until the footage is on\n // screen there is nothing to hold it against, so the scrim would just be\n // darkening the brand gradient it was never meant to touch — the scene\n // reads as a muddy, vignetted version of the gradient scenes beside it.\n // That window is not rare: it covers the whole load, and it never ends for\n // a dead URL, a blocked host, or an empty stock search.\n //\n // So the media has to paint before anything darkens for it. Both edges of\n // the swap land on the same commit — scrim and picture appear together,\n // and the fallback is the clean gradient the docs always promised.\n const [mediaPaint, setMediaPaint] = useState<MediaPaintState>(() =>\n initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster),\n );\n // A stock-video poster is useful only until this exact <video> can paint its\n // own first frame. Desktop incoming scenes decode while opacity is zero, so\n // loadeddata is sufficient there. Mobile WebKit cannot mount that incoming\n // decoder safely; loadeddata can precede the first presented frame and\n // removing the poster in that gap exposes the brand gradient.\n const [decodedVideoUrl, setDecodedVideoUrl] = useState<string>();\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 // Video playback control — same pause/seek logic bg-media used pre-extract.\n const videoRef = useRef<HTMLVideoElement>(null);\n const videoStarted = useRef(false);\n\n useEffect(() => {\n const vid = videoRef.current;\n if (!vid) return;\n if (!isPlaying) {\n vid.pause();\n videoStarted.current = false;\n return;\n }\n if (!videoStarted.current) {\n vid.playbackRate = 1;\n // A fresh element is already at zero. Avoid seeking it before metadata\n // exists; only a resumed scene needs rewinding.\n if (vid.currentTime > 0) vid.currentTime = 0;\n vid.play().catch(() => {});\n videoStarted.current = true;\n }\n }, [progress, isPlaying]);\n\n // Release the decoder on unmount. Without this, iOS Safari keeps the\n // video's decoder buffer alive after the React node is gone — each\n // scene transition (or play/pause/play cycle that remounts the active\n // scene) leaks one decoder, eventually crossing the renderer's memory\n // ceiling and triggering \"A problem repeatedly occurred.\" Same recipe\n // as #409's CanvasPreview preload cleanup: pause → clear src → load().\n // Capture the ref at mount-time so the cleanup uses the same node we\n // mounted (the ref's .current is stale by unmount).\n useEffect(() => {\n const vid = videoRef.current;\n return () => {\n if (!vid) return;\n vid.pause();\n vid.removeAttribute(\"src\");\n vid.load();\n };\n }, []);\n\n return (\n <>\n <BrandGradientOverlay\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n seed={gradSeed}\n />\n\n {showMedia &&\n (resolved === \"video\" ? (\n <video\n ref={videoRef}\n src={mediaUrl}\n // Poster paints during the decode window so the user sees the\n // (still) first frame instead of a transparent <video> letting\n // the brand gradient show through. Pexels returns a thumbnail\n // image alongside each video; fillPexelsUrls stores it in\n // `variables.mediaPoster`. Layered defense alongside preload=\"auto\"\n // below: on desktop the byte preloader makes decode fast, on\n // mobile (where the preloader skips video pre-mounting to dodge\n // the iOS Safari memory crash) the poster is the primary shield.\n poster={decodedVideoUrl === mediaUrl ? undefined : mediaPoster || undefined}\n muted\n loop\n playsInline\n // preload=\"auto\" — without it, browsers default to \"metadata\":\n // they only load the container/dimensions, not the byte stream\n // needed to decode frames. The element then renders transparent\n // until the first decoded frame arrives, letting the brand\n // gradient flash through whenever a scene mid-playback transitions\n // to a media backdrop. The parent preloader caches the bytes, but\n // decoder state is per-element, so the active mount still has to\n // decode the first frame; \"auto\" kicks that work off the instant\n // the element mounts.\n preload=\"auto\"\n onLoadedData={(event) => {\n setMediaPaint(\"ready\");\n const video = event.currentTarget;\n if (!limitsConcurrentVideoDecoders()) {\n setDecodedVideoUrl(mediaUrl);\n return;\n }\n // requestVideoFrameCallback fires only after WebKit submits an\n // actual frame to the compositor. Until then the poster stays\n // in place; browsers without the API safely keep the poster\n // attribute and replace it themselves when playback begins.\n video.requestVideoFrameCallback?.(() => {\n if (video.isConnected) setDecodedVideoUrl(mediaUrl);\n });\n }}\n onError={() => setMediaPaint(\"failed\")}\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: resolvedPosition,\n // Mobile WebKit can briefly make a newly mounted video layer\n // transparent while handing the decoder over from the previous\n // scene. Keep the already-warmed poster painted behind the\n // replaced content so that gap never exposes the gradient.\n backgroundImage: mediaPoster ? `url(${mediaPoster})` : undefined,\n backgroundSize: \"cover\",\n backgroundPosition: resolvedPosition,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n }}\n />\n ) : (\n <div\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n backgroundImage: `url(${mediaUrl})`,\n backgroundSize: \"cover\",\n backgroundPosition: resolvedPosition,\n }}\n />\n ))}\n\n {showTreatment &&\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"
|
|
52
|
+
"content": "/**\n * SceneBackground — shared backdrop component for any scene template that\n * wants to support both a brand-color gradient and stock media (Pexels\n * photo / video) as an alternate atmosphere.\n *\n * Usage:\n * <SceneBackground\n * style={style}\n * progress={progress}\n * sceneDuration={sceneDuration}\n * width={width}\n * height={height}\n * mediaUrl={String(variables.mediaUrl || \"\")}\n * mediaType={String(variables.mediaType || \"auto\")}\n * seed={String(variables.texts || \"\")}\n * isPlaying={isPlaying}\n * />\n * ... template's content layered on top\n *\n * Behavior:\n * - Brand gradient is the always-on backdrop (uses BrandGradientOverlay).\n * - When mediaUrl is set and mediaType isn't \"gradient\", the photo/video\n * covers the gradient. Legibility is then split between two instruments:\n * eased scrims shaped to where the template's copy sits (`textAnchor`),\n * and a per-glyph halo on the type itself (MEDIA_TEXT_SHADOW). Neither\n * alone can hold white type over a blown-out highlight without flattening\n * the picture; together they do it at roughly half the darkening.\n * - mediaType=\"gradient\" deliberately ignores mediaUrl and renders only\n * the brand gradient. First-class atmospheric mode.\n * - When mediaUrl is empty, 404s, is blocked, or Pexels search returned\n * nothing, the gradient shows through cleanly and no scrim is painted —\n * a scrim over a bare gradient is just a muddy gradient. Enforced, not\n * assumed: the media has to load before anything darkens for it.\n *\n * Extracted from bg-media.tsx so any template can compose it. bg-media\n * now uses this component too — its \"media is the scene\" identity comes\n * from how it positions the title (centered, full-frame), not from\n * duplicated render logic.\n */\n\nimport React, { useEffect, useState } from \"react\";\nimport {\n hasSceneMedia,\n resolveMediaType,\n type ResolvedMediaType,\n} from \"./media-source\";\nimport type { TemplateStyle } from \"../template-context\";\nimport { BrandGradientOverlay } from \"../backgrounds\";\nimport { getBackgroundTransform } from \"../backgrounds\";\nimport { useExternalVideoBackdrop } from \"./external-video-backdrop\";\nimport { resolveMediaPosition, type MediaPosition } from \"./media-position\";\nimport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\n\nexport { hasSceneMedia, resolveMediaType };\nexport { resolveMediaPosition } from \"./media-position\";\nexport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\nexport type { ResolvedMediaType };\n\nexport type { MediaPosition };\nexport type MediaTreatment = \"subtle\" | \"cinematic\" | \"text-safe\";\n\nexport function resolveMediaTreatment(value: string): MediaTreatment {\n return 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 const vignette: MediaTreatmentLayer = {\n id: \"vignette\",\n background:\n treatment === \"subtle\"\n ? `radial-gradient(ellipse at center, ${easedStops(0.28, 45, 100, \"fade-in\")})`\n : `radial-gradient(ellipse at center, ${easedStops(0.72, 32, 100, \"fade-in\")})`,\n };\n if (treatment === \"subtle\") return [vignette];\n\n const textSafe = treatment === \"text-safe\";\n const layers: MediaTreatmentLayer[] = [vignette];\n\n if (anchor !== \"bottom\") {\n layers.push({\n id: \"center-scrim\",\n background: textSafe\n ? `radial-gradient(ellipse 92% 58% at 50% 50%, ${easedStops(0.46, 34, 90, \"fade-out\")})`\n : `radial-gradient(ellipse 88% 52% at 50% 50%, ${easedStops(0.26, 30, 88, \"fade-out\")})`,\n });\n }\n\n if (anchor !== \"center\") {\n layers.push({\n id: \"bottom-scrim\",\n background: `linear-gradient(to top, ${easedStops(textSafe ? 0.64 : 0.5, 8, 100, \"fade-out\")})`,\n style: { top: \"55%\" },\n });\n }\n\n return layers;\n}\n\n/**\n * Whether the backdrop is actually painting, which is what decides if a scrim\n * is earned. \"pending\" is a browser-only state: static and export renders\n * never run effects and never wait on a network, so they start (and stay)\n * ready and their output is unchanged.\n */\ntype MediaPaintState = \"pending\" | \"ready\" | \"failed\";\n\nfunction initialMediaPaint(\n wantsMedia: boolean,\n resolved: ResolvedMediaType,\n mediaUrl: string,\n mediaPoster: string | undefined,\n): MediaPaintState {\n if (typeof window === \"undefined\") return \"ready\";\n if (!wantsMedia) return \"ready\";\n // A poster paints the video's frame immediately, so the scene is already\n // showing footage even though the stream is still decoding.\n if (resolved === \"video\") return mediaPoster ? \"ready\" : \"pending\";\n if (typeof Image === \"undefined\") return \"ready\";\n // Preloaded or browser-cached media decodes synchronously. Reporting it\n // ready on the first render keeps the common mid-playback case free of a\n // gradient-then-photo flicker.\n const cached = new Image();\n cached.src = mediaUrl;\n return cached.complete && cached.naturalWidth > 0 ? \"ready\" : \"pending\";\n}\n\nexport function getMediaBackgroundProps(variables: Record<string, unknown>) {\n return {\n mediaUrl: String(variables.mediaUrl || \"\"),\n mediaType: String(variables.mediaType || \"auto\"),\n mediaPoster: String(variables.mediaPoster || \"\"),\n mediaPosition: String(variables.mediaPosition || \"center\"),\n mediaTreatment: String(variables.mediaTreatment || \"cinematic\"),\n };\n}\n\nexport interface SceneBackgroundProps {\n style: TemplateStyle;\n progress: number;\n sceneDuration?: number;\n width: number;\n height: number;\n mediaUrl?: string;\n mediaType?: string;\n /** Still image URL shown while the <video> backdrop decodes its first\n * frame. Without it the element renders transparent during the\n * ~50–400ms decode window and the gradient flashes through. */\n mediaPoster?: string;\n /** Cover-crop focal anchor. Keeps the important edge/subject visible. */\n mediaPosition?: string;\n /** Overlay recipe: subtle, cinematic, or stronger text-safe contrast. */\n mediaTreatment?: string;\n /** Where this template's copy sits, so the scrim is shaped to the type\n * instead of to the frame. Defaults to \"full\" (scrim both the middle and\n * the lower third) for templates that have not declared an anchor. */\n textAnchor?: MediaTextAnchor;\n /** Background motion effect (drift / pulse / Ken Burns). Applied to the photo/video. */\n backgroundEffect?: string;\n /** Stable seed for the gradient breathing animation. Pass the scene's\n * text content (or any stable string) — it's hashed deterministically. */\n seed?: number | string;\n /** Pause video when preview is paused. Defaults to true (export path). */\n isPlaying?: boolean;\n beatIntensity?: number;\n}\n\nexport const SceneBackground: React.FC<SceneBackgroundProps> = ({\n style,\n progress,\n sceneDuration,\n width: _width, // accepted for symmetry; not currently used in render\n height: _height,\n mediaUrl = \"\",\n mediaType = \"auto\",\n mediaPoster,\n mediaPosition = \"center\",\n mediaTreatment = \"cinematic\",\n textAnchor = \"full\",\n backgroundEffect,\n seed,\n isPlaying = true,\n beatIntensity = 0,\n}) => {\n void _width;\n void _height;\n const resolved = resolveMediaType(mediaType, mediaUrl);\n const wantsMedia = resolved !== \"gradient\" && !!mediaUrl;\n const externalVideoBackdrop = useExternalVideoBackdrop();\n const hasExternalVideoBackdrop = externalVideoBackdrop !== false && resolved === \"video\";\n const externalVideoFailed = externalVideoBackdrop === \"fallback\" && resolved === \"video\";\n const externalVideoReady = externalVideoBackdrop === \"ready\" && resolved === \"video\";\n\n // A scrim exists to hold type against footage. Until the footage is on\n // screen there is nothing to hold it against, so the scrim would just be\n // darkening the brand gradient it was never meant to touch — the scene\n // reads as a muddy, vignetted version of the gradient scenes beside it.\n // That window is not rare: it covers the whole load, and it never ends for\n // a dead URL, a blocked host, or an empty stock search.\n //\n // So the media has to paint before anything darkens for it. Both edges of\n // the swap land on the same commit — scrim and picture appear together,\n // and the fallback is the clean gradient the docs always promised.\n const [mediaPaint, setMediaPaint] = useState<MediaPaintState>(() =>\n initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster),\n );\n useEffect(() => {\n setMediaPaint(initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster));\n // Video reports its own paint through onLoadedData / onError below.\n if (!wantsMedia || resolved !== \"photo\") return;\n if (typeof Image === \"undefined\") return;\n let cancelled = false;\n const probe = new Image();\n probe.onload = () => {\n if (!cancelled) setMediaPaint(\"ready\");\n };\n probe.onerror = () => {\n if (!cancelled) setMediaPaint(\"failed\");\n };\n probe.src = mediaUrl;\n if (probe.complete) setMediaPaint(probe.naturalWidth > 0 ? \"ready\" : \"failed\");\n return () => {\n cancelled = true;\n probe.onload = null;\n probe.onerror = null;\n };\n }, [mediaUrl, mediaPoster, resolved, wantsMedia]);\n\n // The element stays mounted while pending — that is what loads it. Only a\n // confirmed failure takes it back out.\n const showMedia = wantsMedia && mediaPaint !== \"failed\";\n const showTreatment = wantsMedia && mediaPaint === \"ready\";\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const resolvedTreatment = resolveMediaTreatment(mediaTreatment);\n const treatmentLayers = getMediaTreatmentLayers(resolvedTreatment, textAnchor);\n\n const gradSeed =\n typeof seed === \"number\"\n ? seed\n : typeof seed === \"string\"\n ? seed.split(\"\").reduce((acc, c) => acc + c.charCodeAt(0), 0)\n : 0;\n\n const bgTransform = getBackgroundTransform(\n backgroundEffect,\n progress,\n beatIntensity,\n );\n\n return (\n <>\n {(!hasExternalVideoBackdrop || externalVideoFailed) && (\n <BrandGradientOverlay\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n seed={gradSeed}\n />\n )}\n\n {showMedia && !hasExternalVideoBackdrop &&\n (resolved === \"video\" ? (\n <SceneVideoBackdrop\n mediaUrl={mediaUrl}\n mediaPoster={mediaPoster}\n mediaPosition={mediaPosition}\n backgroundEffect={backgroundEffect}\n progress={progress}\n beatIntensity={beatIntensity}\n isPlaying={isPlaying}\n onReady={() => setMediaPaint(\"ready\")}\n onError={() => setMediaPaint(\"failed\")}\n />\n ) : (\n <div\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n backgroundImage: `url(${mediaUrl})`,\n backgroundSize: \"cover\",\n backgroundPosition: resolvedPosition,\n }}\n />\n ))}\n\n {(hasExternalVideoBackdrop ? externalVideoReady : showTreatment) && !externalVideoFailed &&\n treatmentLayers.map((layer) => (\n <div\n key={layer.id}\n data-media-treatment={resolvedTreatment}\n data-media-overlay={layer.id}\n style={{\n position: \"absolute\",\n inset: 0,\n background: layer.background,\n pointerEvents: \"none\",\n ...layer.style,\n }}\n />\n ))}\n </>\n );\n};\n"
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
"path": "src/visual-system/scene-templates/types.ts",
|