@vanillaskyai/video 0.10.23 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/PUBLIC-API.md +45 -438
- package/README.md +53 -97
- package/dist/builtin-metadata-OT6V7TB4.js +8 -0
- package/dist/{chapter-title-2JVDU62E.js → chapter-title-2RCXX7SN.js} +1 -2
- package/dist/{chunk-PDQFQIQW.js → chunk-3EQ6PVWL.js} +1 -1
- package/dist/chunk-4G4JBMCM.js +37 -0
- package/dist/{chunk-7AA2JWHZ.js → chunk-5DOQTIMD.js} +6 -6
- package/dist/chunk-666HGTVZ.js +41 -0
- package/dist/{chunk-7M56IUUX.js → chunk-6Z3ID54H.js} +0 -18
- package/dist/{chunk-5BSJLT6H.js → chunk-E7FHBLAC.js} +16 -29
- package/dist/chunk-HIQM4J3U.js +30 -0
- package/dist/chunk-ISWQ6T5X.js +36 -0
- package/dist/{chunk-224QNWRA.js → chunk-K5J7ESRO.js} +1 -10
- package/dist/chunk-KMVRBUL5.js +50 -0
- package/dist/chunk-NM4CXXZY.js +25 -0
- package/dist/{chunk-44WND2VP.js → chunk-V2CP7PVY.js} +19 -63
- package/dist/{chunk-3O7OMMMF.js → chunk-WZESNEPT.js} +0 -1
- package/dist/{chunk-RXTN2CW6.js → chunk-Z3DLSLAJ.js} +1 -1
- package/dist/cinema-media-YZ2UANJG.js +365 -0
- package/dist/cli.js +154 -1748
- package/dist/{compose-video-BH5K6XWT.js → compose-video-H2YUQDUY.js} +4 -4
- package/dist/{events-B4YCc4vc.d.ts → events-BZAfl0Dm.d.ts} +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -6
- package/dist/preload-media-23JXUFCF.js +77 -0
- package/dist/react.d.ts +39 -15
- package/dist/react.js +1350 -739
- package/dist/scene-validation-SGLLLFY5.js +9 -0
- package/dist/server.d.ts +67 -117
- package/dist/server.js +262 -614
- package/dist/test.d.ts +2 -2
- package/dist/test.js +21 -21
- package/dist/{text-stream-LD364KBI.js → text-stream-SOVLYR2L.js} +2 -2
- package/dist/{types-BqB8zC9u.d.ts → types-CG-kPI81.d.ts} +3 -1
- package/dist/{types-DdZw4GRQ.d.ts → types-ht-Zw3Wv.d.ts} +1 -1
- package/docs/agent-integration.md +19 -64
- package/docs/architecture.md +80 -106
- package/docs/customization.md +49 -66
- package/docs/development.md +18 -17
- package/docs/errors.md +4 -4
- package/docs/getting-started.md +74 -93
- package/docs/media-and-audio.md +9 -7
- package/docs/performance.md +11 -6
- package/docs/persistence.md +3 -7
- package/docs/production.md +4 -15
- package/docs/prompt-and-input.md +1 -2
- package/docs/provider-integration.md +134 -181
- package/docs/reference/protocol.md +5 -14
- package/docs/reference/provider-adapters.md +21 -13
- package/docs/security.md +1 -14
- package/docs/testing.md +56 -123
- package/package.json +4 -25
- package/starters/video-chat/.env.example +12 -4
- package/starters/video-chat/.env.native.example +13 -0
- package/starters/video-chat/README.md +72 -11
- package/starters/video-chat/package.json +1 -1
- package/starters/video-chat/providers/text-native.ts +94 -0
- package/starters/video-chat/providers/text.ts +19 -0
- package/starters/video-chat/providers/transcription.ts +32 -0
- package/starters/video-chat/providers/video-custom.ts +45 -0
- package/starters/video-chat/providers/video-delivery.ts +57 -0
- package/starters/video-chat/providers/video-google.ts +48 -0
- package/starters/video-chat/providers/video-job.ts +103 -0
- package/starters/video-chat/providers/video-runway.ts +45 -0
- package/starters/video-chat/providers/video.ts +39 -63
- package/starters/video-chat/server.ts +3 -29
- package/starters/video-chat/vite.config.ts +2 -8
- package/styles/video-chat.css +8 -105
- package/dist/builtin-server-W4PLJUZ5.js +0 -8
- package/dist/catalog-types-WTbLP6Jh.d.ts +0 -78
- package/dist/check-runtime.d.ts +0 -15
- package/dist/check-runtime.js +0 -96
- package/dist/chunk-2E6T633S.js +0 -27
- package/dist/chunk-4YM2M62S.js +0 -13
- package/dist/chunk-4ZJLPHBV.js +0 -684
- package/dist/chunk-5JBMYQP6.js +0 -156
- package/dist/chunk-73NTSFFI.js +0 -81
- package/dist/chunk-EGVQODKU.js +0 -83
- package/dist/chunk-HFVNAPHZ.js +0 -35
- package/dist/chunk-IIN5M5HW.js +0 -697
- package/dist/chunk-IQMYK5DX.js +0 -133
- package/dist/chunk-IR44XKBI.js +0 -46
- package/dist/chunk-JKVOBTRO.js +0 -145
- package/dist/chunk-LVM5Q2DL.js +0 -68
- package/dist/chunk-M4QTEJTK.js +0 -709
- package/dist/chunk-QSBDB4J2.js +0 -16
- package/dist/chunk-R3XAOMKP.js +0 -29
- package/dist/chunk-SPVTJH3F.js +0 -24
- package/dist/chunk-YAHT3LST.js +0 -663
- package/dist/chunk-ZD2VTUYR.js +0 -28
- package/dist/cinema-media-HYCDG65Z.js +0 -11
- package/dist/comparison-XXAP4S4J.js +0 -36
- package/dist/editorial-timeline-VUQC6KPA.js +0 -42
- package/dist/key-figure-4TJK7HLT.js +0 -29
- package/dist/kit-DrRpdn0p.d.ts +0 -79
- package/dist/mobile-message-FIHAO6XC.js +0 -49
- package/dist/preload-media-LJXWKTWG.js +0 -57
- package/dist/quote-GRPIYKQJ.js +0 -32
- package/dist/system-prompt-AG26KJAA.js +0 -12
- package/dist/template-catalog.d.ts +0 -400
- package/dist/template-catalog.js +0 -6
- package/dist/templates.d.ts +0 -25
- package/dist/templates.js +0 -102
- package/dist/validate-4OUD2CLX.js +0 -10
- package/docs/concepts.md +0 -107
- package/docs/custom-templates.md +0 -346
- package/docs/immersive-interface.md +0 -86
- package/docs/motion-and-effects.md +0 -109
- package/docs/reference/design-system.html +0 -125
- package/docs/responsive-orientation.md +0 -37
- package/docs/streaming-protocol.md +0 -15
- package/examples/custom-template/README.md +0 -19
- package/examples/custom-template/minimal-text.tsx +0 -82
- package/examples/custom-template/structured-data.tsx +0 -104
- package/registry/items/backgrounds.json +0 -70
- package/registry/items/chapterTitle.json +0 -80
- package/registry/items/cinemaMedia.json +0 -136
- package/registry/items/comparison.json +0 -168
- package/registry/items/editorialTimeline.json +0 -172
- package/registry/items/keyFigure.json +0 -162
- package/registry/items/mobileMessage.json +0 -153
- package/registry/items/motion.json +0 -45
- package/registry/items/quote.json +0 -161
- package/registry/items/template-context.json +0 -31
- package/registry/items/theme.json +0 -47
- package/registry/items/typography.json +0 -47
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
-
"name": "motion",
|
|
4
|
-
"type": "registry:lib",
|
|
5
|
-
"title": "Motion",
|
|
6
|
-
"description": "One deterministic progress-driven API, from raw curves to composed effects.",
|
|
7
|
-
"dependencies": [
|
|
8
|
-
"react"
|
|
9
|
-
],
|
|
10
|
-
"files": [
|
|
11
|
-
{
|
|
12
|
-
"path": "src/visual-system/motion/index.ts",
|
|
13
|
-
"type": "registry:lib",
|
|
14
|
-
"target": "vanillasky/motion/index.ts",
|
|
15
|
-
"content": "/**\n * Unified motion surface — ONE import for both motion vocabularies:\n *\n * - Core curves (src/visual-system/motion/curves.ts): `interpolate`,\n * `spring`, `SPRING_SMOOTH/SNAPPY/BOUNCY/CRISP`, `Easing`, `stagger`,\n * `cubicBezier`. Used by every built-in template.\n * - Motion stdlib (src/visual-system/motion/effects.ts): the export-verified\n * high-level helpers — `phase`, `staggerWindow`, `cascade`, `typewriter`,\n * `countUp`, `punch`, `glow`, `meshGradient`, `particles`, `burst`,\n * `sweep`, `drift`, `orbit`, `EASE`, and friends.\n *\n * This is the stable entrypoint for templates, custom scenes, and the public\n * registry. Keep implementation files behind this facade.\n */\n\nexport * from \"./curves\";\nexport * from \"./effects\";\n"
|
|
16
|
-
},
|
|
17
|
-
{
|
|
18
|
-
"path": "src/visual-system/motion/effects.ts",
|
|
19
|
-
"type": "registry:lib",
|
|
20
|
-
"target": "vanillasky/motion/effects.ts",
|
|
21
|
-
"content": "/**\n * High-level, export-safe motion effects for project-owned scene templates.\n * available to project-owned scene templates through the motion entry point.\n *\n * Why this exists: handing a code-writing model raw CSS makes it reinvent\n * basics, badly. These helpers encode the craft moves of Remotion-grade\n * pieces (springy staggers, eased counters, layered glows, seeded particle\n * fields, path draws) as single calls the model composes.\n *\n * Hard rules — every helper in this file:\n * - is PURE and DETERMINISTIC: same args → same output, always.\n * - touches NO DOM, NO globals, NO Date/Math.random/timers.\n * - is progress-driven: animation state comes in as `progress` (0..1).\n * - emits only export-safe CSS: no `filter`, no transitions, no animations.\n * (The export pipeline rasterizes SVG-as-image — see CLAUDE.md.)\n *\n * Naming is deliberately collision-resistant: multi-word identifiers a model\n * won't reach for as a loop variable (round-2 eval found `s`/`dim` shadowed\n * by `.map((s, si) => …)` params, producing NaN geometry).\n *\n * Keep every helper deterministic and compatible with the preview and export\n * paths.\n */\n\nimport type { CSSProperties } from \"react\";\nimport { cubicBezier } from \"./curves\";\n\nexport type EasingFn = (t: number) => number;\n\n/**\n * Re-export of the canonical cubic-bezier solver (also `Easing.bezier`).\n * cubicBezier(0.16, 1, 0.3, 1) === EASE.crispEnter\n */\nexport { cubicBezier };\n\n// ─── internals ───────────────────────────────────────────────────\n\nfunction clamp01(t: number): number {\n return t < 0 ? 0 : t > 1 ? 1 : t;\n}\n\n/** Hex → rgba with alpha. Non-hex colors pass through unchanged. */\nfunction alpha(color: string, a: number): string {\n const m = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(color);\n if (!m) return color;\n const h = m[1];\n const full = h.length === 3 ? h.split(\"\").map((c) => c + c).join(\"\") : h;\n const r = parseInt(full.slice(0, 2), 16);\n const g = parseInt(full.slice(2, 4), 16);\n const b = parseInt(full.slice(4, 6), 16);\n return `rgba(${r},${g},${b},${Math.round(clamp01(a) * 1000) / 1000})`;\n}\n\n// ─── timing / physics ────────────────────────────────────────────\n\n/**\n * Named easing presets. Enter on the ease-OUT family, exit on ease-IN.\n * crispEnter / editorial / pop are the canonical VanillaSky curves from\n * docs/motion-library.md §1.\n */\nexport const EASE: Record<string, EasingFn> = {\n linear: (t) => t,\n outExpo: (t) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t)),\n outQuint: (t) => 1 - Math.pow(1 - t, 5),\n outCubic: (t) => 1 - Math.pow(1 - t, 3),\n /** Decelerating overshoot — lands from beyond 1. Great for card arrivals. */\n outBack: (t) => {\n if (t <= 0) return 0;\n if (t >= 1) return 1;\n const c1 = 1.70158;\n const c3 = c1 + 1;\n return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);\n },\n inQuad: (t) => t * t,\n inCubic: (t) => t * t * t,\n inOutCubic: (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2),\n /** Pulls back below 0 before launching. Use for wind-up exits/entrances. */\n anticipate: (t) => {\n if (t <= 0) return 0;\n if (t >= 1) return 1;\n const c = 2.0;\n return t * t * ((c + 1) * t - c);\n },\n /** CRISP_ENTER — UI slide-ins, tight purposeful reveals. */\n crispEnter: cubicBezier(0.16, 1, 0.3, 1),\n /** EDITORIAL — calm holds, photo reveals, chart draws. */\n editorial: cubicBezier(0.45, 0, 0.55, 1),\n /** POP — stat pops, single-element arrivals with small overshoot. */\n pop: cubicBezier(0.34, 1.56, 0.64, 1),\n};\n\n/**\n * Remap scene progress to a local 0..1 inside a [start, end] window,\n * clamped, optionally eased. The backbone of multi-beat scenes.\n *\n * const t = phase(progress, [0.2, 0.6], EASE.crispEnter);\n */\nexport function phase(\n progress: number,\n window: readonly [number, number],\n easing?: EasingFn,\n): number {\n const [start, end] = window;\n if (end <= start) return progress >= end ? 1 : 0;\n const t = clamp01((progress - start) / (end - start));\n return easing ? easing(t) : t;\n}\n\nexport interface StaggerWindowOptions {\n /** 0 = strictly sequential, 1 = all items animate together. Default 0.6. */\n overlap?: number;\n /** Animation order: \"start\" (default), \"end\" (reverse), \"center\" (outward), \"random\" (seeded shuffle). */\n from?: \"start\" | \"end\" | \"center\" | \"random\";\n /** Seed for from: \"random\" — same seed → same order every frame. Default \"stagger\". */\n seed?: string | number;\n easing?: EasingFn;\n}\n\n/**\n * Per-item progress for staggered groups with OVERLAPPING windows — unlike\n * `stagger()`, which is delay-based. Item 0 starts at progress 0; the last\n * item ends exactly at 1, so the stagger always fills the scene.\n *\n * items.map((item, i) => {\n * const t = staggerWindow(progress, i, items.length, { overlap: 0.6, easing: EASE.pop });\n * return <div style={{ opacity: Math.min(1, t), transform: `translateY(${(1 - t) * 40}px)` }}>…</div>;\n * })\n */\nexport function staggerWindow(\n progress: number,\n index: number,\n count: number,\n options?: StaggerWindowOptions,\n): number {\n const { overlap = 0.6, from = \"start\", seed = \"stagger\", easing } = options ?? {};\n const n = Math.max(1, Math.floor(count));\n const ov = clamp01(overlap);\n let order = Math.min(Math.max(index, 0), n - 1);\n if (from === \"end\") order = n - 1 - order;\n else if (from === \"center\") order = Math.abs(order - (n - 1) / 2) * 2;\n else if (from === \"random\") order = rand01(seed, order) * (n - 1);\n const widthFrac = 1 / (1 + (n - 1) * (1 - ov));\n const step = n > 1 ? (1 - widthFrac) / (n - 1) : 0;\n const t = clamp01((progress - order * step) / widthFrac);\n return easing ? easing(t) : t;\n}\n\nexport interface PunchOptions {\n /** Progress at which the punch peaks. Default 0.5. */\n at?: number;\n /** Window width of the punch. Default 0.25. */\n width?: number;\n /** Peak extra scale, e.g. 0.12 → 1 → 1.12 → 1. Default 0.12. */\n amount?: number;\n}\n\n/**\n * A scale multiplier that bumps 1 → 1+amount → 1 around a moment.\n * Use for beat hits and emphasis: `transform: \\`scale(${punch(progress, { at: 0.8 })})\\``.\n */\nexport function punch(progress: number, options?: PunchOptions): number {\n const { at = 0.5, width = 0.25, amount = 0.12 } = options ?? {};\n if (width <= 0) return 1;\n const t = (progress - (at - width / 2)) / width;\n if (t <= 0 || t >= 1) return 1;\n return 1 + amount * Math.sin(t * Math.PI);\n}\n\n// ─── typography ──────────────────────────────────────────────────\n\nexport interface CascadeOptions {\n /** Split unit. Default \"word\". */\n by?: \"word\" | \"char\";\n /** Window overlap between items (see staggerWindow). Default 0.7. */\n overlap?: number;\n easing?: EasingFn;\n /** Entrance travel in px. Default 28. */\n distance?: number;\n /** Travel direction. Default \"up\" (rises into place). */\n direction?: \"up\" | \"down\";\n from?: \"start\" | \"end\" | \"center\";\n}\n\nexport interface CascadeItem {\n /** The word or character. */\n item: string;\n /** Eased local progress 0..1 (may overshoot 1 with springy easings). */\n t: number;\n /** Ready-to-spread inline style: opacity + translate + scale punch-in. */\n style: CSSProperties;\n}\n\n/**\n * Per-word / per-char cascade — the word-cascade archetype as one call.\n * Returns ready-to-render spans:\n *\n * cascade(title, progress).map(({ item, style }, i) => (\n * <span key={i} style={style}>{item}</span>\n * ))\n */\nexport function cascade(text: string, progress: number, options?: CascadeOptions): CascadeItem[] {\n const { by = \"word\", overlap = 0.7, easing = EASE.pop, distance = 28, direction = \"up\", from = \"start\" } =\n options ?? {};\n const items =\n by === \"char\" ? Array.from(String(text)) : String(text).split(/\\s+/).filter((w) => w.length > 0);\n const n = items.length;\n const dir = direction === \"down\" ? -1 : 1;\n return items.map((item, i) => {\n const t = staggerWindow(progress, i, n, { overlap, from, easing });\n const style: CSSProperties = {\n display: \"inline-block\",\n whiteSpace: \"pre\",\n opacity: clamp01(t * 1.4),\n transform: `translateY(${((1 - t) * distance * dir).toFixed(2)}px) scale(${(0.88 + 0.12 * t).toFixed(3)})`,\n ...(by === \"word\" && i < n - 1 ? { marginRight: \"0.26em\" } : null),\n };\n return { item, t, style };\n });\n}\n\nexport interface TypewriterOptions {\n /**\n * Progress range over which typing happens (held after). Default [0, 0.7].\n * (Named `range`, not `window` — the validator bans the `window` token.)\n */\n range?: readonly [number, number];\n easing?: EasingFn;\n /** Cursor blinks per scene. Default 6. Set 0 to hide. */\n blinks?: number;\n}\n\nexport interface TypewriterResult {\n /** The visible slice — render this directly. */\n text: string;\n /** True when the block cursor should be visible this frame. */\n cursorOn: boolean;\n done: boolean;\n}\n\n/**\n * Typewriter via string slicing (never per-char opacity — it reads as fade,\n * not typing). Deterministic blink derived from progress.\n *\n * const tw = typewriter(cmd, progress);\n * <span>{tw.text}{tw.cursorOn ? \"▋\" : \" \"}</span>\n */\nexport function typewriter(text: string, progress: number, options?: TypewriterOptions): TypewriterResult {\n const { range: win = [0, 0.7], easing = EASE.linear, blinks = 6 } = options ?? {};\n const chars = Array.from(String(text));\n const t = phase(progress, win, easing);\n const visible = t >= 1 ? chars.length : Math.floor(t * chars.length);\n const done = visible >= chars.length;\n const cursorOn = blinks > 0 && Math.floor(clamp01(progress) * blinks * 2) % 2 === 0;\n return { text: chars.slice(0, visible).join(\"\"), cursorOn, done };\n}\n\nexport interface CountUpOptions {\n start?: number;\n /** Decimal places. Defaults to 1 if target is non-integer, else 0. */\n decimals?: number;\n easing?: EasingFn;\n /** \"plain\" → 12,847 · \"compact\" → 12.8K. Default \"plain\". */\n format?: \"plain\" | \"compact\";\n prefix?: string;\n suffix?: string;\n}\n\nexport interface CountUpResult {\n value: number;\n /** Formatted display string including prefix/suffix. */\n text: string;\n /** Scale multiplier with a landing punch near the end — apply via transform. */\n scale: number;\n}\n\n/**\n * Eased counter with deterministic formatting (no locale dependence) and a\n * scale punch as the number lands.\n *\n * const { text, scale } = countUp(progress, 12847, { format: \"compact\", suffix: \" users\" });\n * <div style={{ transform: `scale(${scale})` }}>{text}</div>\n */\nexport function countUp(progress: number, target: number, options?: CountUpOptions): CountUpResult {\n const {\n start = 0,\n easing = EASE.outExpo,\n format = \"plain\",\n prefix = \"\",\n suffix = \"\",\n } = options ?? {};\n const decimals = options?.decimals ?? (Number.isInteger(target) ? 0 : 1);\n const e = easing(clamp01(progress));\n const value = start + (target - start) * e;\n let body: string;\n if (format === \"compact\" && Math.abs(value) >= 1000) {\n const units: Array<[number, string]> = [\n [1e9, \"B\"],\n [1e6, \"M\"],\n [1e3, \"K\"],\n ];\n const [div, unit] = units.find(([d]) => Math.abs(value) >= d) as [number, string];\n const scaled = value / div;\n body = `${(Math.round(scaled * 10) / 10).toFixed(Math.abs(scaled) >= 100 ? 0 : 1)}${unit}`;\n } else {\n const fixed = value.toFixed(decimals);\n const [int, frac] = fixed.split(\".\");\n const sign = int.startsWith(\"-\") ? \"-\" : \"\";\n const digits = sign ? int.slice(1) : int;\n let grouped = \"\";\n for (let i = 0; i < digits.length; i++) {\n if (i > 0 && (digits.length - i) % 3 === 0) grouped += \",\";\n grouped += digits[i];\n }\n body = `${sign}${grouped}${frac ? `.${frac}` : \"\"}`;\n }\n const scale = punch(progress, { at: 0.88, width: 0.24, amount: 0.08 });\n return { value, text: `${prefix}${body}${suffix}`, scale };\n}\n\n// ─── layout ──────────────────────────────────────────────────────\n\n/**\n * Absolute-center an element at (xPct%, yPct%) of its container — the\n * translate(-50%,-50%) idiom done right. Extra transforms compose AFTER\n * the centering translate so scale/rotate don't break the anchor.\n *\n * <div style={{ ...center(50, 42, `scale(${pop})`), width: 600 }}>…</div>\n */\nexport function center(xPct = 50, yPct = 50, extraTransform = \"\"): CSSProperties {\n return {\n position: \"absolute\",\n left: `${xPct}%`,\n top: `${yPct}%`,\n transform: `translate(-50%, -50%)${extraTransform ? ` ${extraTransform}` : \"\"}`,\n };\n}\n\n// ─── light / depth (no CSS filter — export-safe) ─────────────────\n\nexport interface GlowOptions {\n /** Diameter in px — scale with sceneScale, e.g. 520 * sceneScale. Default 480. */\n size?: number;\n /** CSS position of the glow center. Defaults \"50%\" / \"50%\". */\n x?: string;\n y?: string;\n /** Core opacity 0..1. Default 0.4. */\n intensity?: number;\n}\n\n/**\n * A layered radial-gradient glow blob (absolutely positioned, pointer-inert).\n * The export-safe replacement for `filter: blur()` light.\n *\n * <div style={glow(accent, { size: 600 * sceneScale, y: \"42%\" })} />\n */\nexport function glow(color: string, options?: GlowOptions): CSSProperties {\n const { size = 480, x = \"50%\", y = \"50%\", intensity = 0.4 } = options ?? {};\n return {\n position: \"absolute\",\n left: x,\n top: y,\n width: size,\n height: size,\n transform: \"translate(-50%, -50%)\",\n borderRadius: \"50%\",\n pointerEvents: \"none\",\n // `closest-side` — the default (farthest-corner) only reaches transparent at\n // the box edge, so a large bloom can show a faint rectangular seam.\n background: `radial-gradient(circle closest-side, ${alpha(color, intensity)} 0%, ${alpha(\n color,\n intensity * 0.45,\n )} 32%, transparent 72%)`,\n };\n}\n\n/**\n * Layered box-shadow stack for believable depth (single shadows look flat).\n * elevation 1 (card resting) … 5 (floating hero). Returns the boxShadow string.\n */\nexport function softShadow(elevation = 3, color = \"#000\"): string {\n const e = Math.min(5, Math.max(1, elevation));\n return [\n `0 ${2 * e}px ${4 * e}px ${alpha(color, 0.16)}`,\n `0 ${6 * e}px ${16 * e}px ${alpha(color, 0.2)}`,\n `0 ${12 * e}px ${40 * e}px ${alpha(color, 0.24)}`,\n ].join(\", \");\n}\n\n/**\n * Full-bleed darkened-edges overlay (focus pull). strength 0..1.\n * Render LAST inside the body so it sits above content, or depth \"foreground\".\n */\nexport function vignette(strength = 0.5): CSSProperties {\n return {\n position: \"absolute\",\n inset: 0,\n pointerEvents: \"none\",\n background: `radial-gradient(ellipse at 50% 45%, rgba(0,0,0,0) 52%, rgba(0,0,0,${\n Math.round(clamp01(strength) * 100) / 100\n }) 100%)`,\n };\n}\n\n/**\n * Deterministic mesh-gradient background string — 2-4 colors as soft radial\n * blobs at seeded positions over a base layer. Use as `background`.\n *\n * background: meshGradient([accent, secondary, \"#1a1040\"], \"hero\")\n */\nexport function meshGradient(colors: string[], seed: string | number = 1): string {\n const list = colors.length > 0 ? colors.slice(0, 4) : [\"#222\"];\n const layers = list.map((c, i) => {\n const cx = Math.round(12 + rand01(seed, i * 3) * 76);\n const cy = Math.round(10 + rand01(seed, i * 3 + 1) * 80);\n const r = Math.round(40 + rand01(seed, i * 3 + 2) * 35);\n return `radial-gradient(circle at ${cx}% ${cy}%, ${alpha(c, 0.85)} 0%, transparent ${r}%)`;\n });\n layers.push(`linear-gradient(180deg, ${alpha(list[0], 0.5)} 0%, rgba(0,0,0,0.9) 100%)`);\n return layers.join(\", \");\n}\n\n/**\n * Film-grain texture overlay — a pre-rasterized 96×96 seeded-noise PNG tile\n * (data URI). Why PNG and not SVG feTurbulence: SVG <filter> elements do NOT\n * execute when a data-URI SVG is rasterized inside the export pipeline's\n * SVG-as-image pass. PNG data URIs render identically in\n * preview and export.\n *\n * The tile was generated once from rand01(\"vanillasky-grain\", i) — fully\n * deterministic. `seed` shifts the tile offset so layered grains never align.\n */\nconst GRAIN_TILE_PX = 96;\nconst GRAIN_TILE_URI =\n \"data:image/png;base64,\" +\n \"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\";\n\nexport function grain(opacity = 0.08, seed: string | number = 2): CSSProperties {\n const ox = Math.floor(rand01(seed, 0) * GRAIN_TILE_PX);\n const oy = Math.floor(rand01(seed, 1) * GRAIN_TILE_PX);\n return {\n position: \"absolute\",\n inset: 0,\n pointerEvents: \"none\",\n opacity: clamp01(opacity),\n backgroundImage: `url(\"${GRAIN_TILE_URI}\")`,\n backgroundRepeat: \"repeat\",\n backgroundPosition: `${ox}px ${oy}px`,\n backgroundSize: `${GRAIN_TILE_PX}px ${GRAIN_TILE_PX}px`,\n };\n}\n\nexport interface DriftOptions {\n /** Total x travel in px over the scene. Default 0. */\n x?: number;\n /** Total y travel in px over the scene. Default 0. */\n y?: number;\n easing?: EasingFn;\n /** Extra transform appended after the drift translate. */\n extraTransform?: string;\n}\n\n/**\n * Slow continuous travel — returns a transform string. Layer different\n * x/y per element for parallax depth (background drifts less than focal).\n *\n * transform: drift(progress, { y: -40 * sceneScale })\n */\nexport function drift(progress: number, options?: DriftOptions): string {\n const { x = 0, y = 0, easing = EASE.editorial, extraTransform = \"\" } = options ?? {};\n const e = easing(clamp01(progress));\n return `translate(${(x * e).toFixed(2)}px, ${(y * e).toFixed(2)}px)${\n extraTransform ? ` ${extraTransform}` : \"\"\n }`;\n}\n\n// ─── shape / SVG ─────────────────────────────────────────────────\n\n/**\n * Stroke draw-on for SVG paths/circles/polylines. Pass the path length\n * (circle: 2πr). Spread onto the SVG element's style or attributes.\n *\n * <path d=\"…\" style={{ ...pathDraw(progress, 1200), stroke: accent, fill: \"none\" }} />\n */\nexport function pathDraw(\n progress: number,\n pathLength: number,\n easing: EasingFn = EASE.editorial,\n): { strokeDasharray: number; strokeDashoffset: number } {\n const e = easing(clamp01(progress));\n return { strokeDasharray: pathLength, strokeDashoffset: pathLength * (1 - e) };\n}\n\nexport interface OrbitOptions {\n /** Orbit center. */\n cx: number;\n cy: number;\n radius: number;\n /** Full revolutions over the scene. Default 1. */\n turns?: number;\n /** Start angle in degrees, -90 = 12 o'clock. Default -90. */\n startDeg?: number;\n clockwise?: boolean;\n}\n\n/**\n * Position on a circular orbit at the given progress.\n * Returns { x, y, deg } — deg is the current angle (use for rotate()).\n */\nexport function orbit(progress: number, options: OrbitOptions): { x: number; y: number; deg: number } {\n const { cx, cy, radius, turns = 1, startDeg = -90, clockwise = true } = options;\n const deg = startDeg + (clockwise ? 1 : -1) * clamp01(progress) * turns * 360;\n const rad = (deg * Math.PI) / 180;\n return { x: cx + Math.cos(rad) * radius, y: cy + Math.sin(rad) * radius, deg };\n}\n\n/**\n * Interpolate between two same-length point lists → SVG `points` string\n * for <polygon>/<polyline> morphs.\n *\n * <polygon points={morph(progress, triangle, star, EASE.crispEnter)} fill={accent} />\n */\nexport function morph(\n progress: number,\n fromPoints: ReadonlyArray<readonly [number, number]>,\n toPoints: ReadonlyArray<readonly [number, number]>,\n easing: EasingFn = EASE.inOutCubic,\n): string {\n const e = easing(clamp01(progress));\n const n = Math.min(fromPoints.length, toPoints.length);\n const pts: string[] = [];\n for (let i = 0; i < n; i++) {\n const x = fromPoints[i][0] + (toPoints[i][0] - fromPoints[i][0]) * e;\n const y = fromPoints[i][1] + (toPoints[i][1] - fromPoints[i][1]) * e;\n pts.push(`${x.toFixed(2)},${y.toFixed(2)}`);\n }\n return pts.join(\" \");\n}\n\nexport type SweepDirection = \"left\" | \"right\" | \"up\" | \"down\" | \"open\";\n\n/**\n * Masked wipe reveal via clip-path inset (verified export-safe).\n * \"left\" reveals left→right, \"open\" reveals center-out.\n *\n * <div style={{ ...sweep(progress, \"left\"), position: \"relative\" }}>…</div>\n */\nexport function sweep(\n progress: number,\n direction: SweepDirection = \"left\",\n easing: EasingFn = EASE.crispEnter,\n): { clipPath: string } {\n const e = clamp01(easing(clamp01(progress)));\n const rest = ((1 - e) * 100).toFixed(2);\n switch (direction) {\n case \"right\":\n return { clipPath: `inset(0% 0% 0% ${rest}%)` };\n case \"up\":\n return { clipPath: `inset(${rest}% 0% 0% 0%)` };\n case \"down\":\n return { clipPath: `inset(0% 0% ${rest}% 0%)` };\n case \"open\":\n return { clipPath: `inset(0% ${(parseFloat(rest) / 2).toFixed(2)}% 0% ${(parseFloat(rest) / 2).toFixed(2)}%)` };\n case \"left\":\n default:\n return { clipPath: `inset(0% ${rest}% 0% 0%)` };\n }\n}\n\n// ─── particles ───────────────────────────────────────────────────\n\n/**\n * Deterministic pseudo-random 0..1 from a seed + stream index.\n * FNV-1a + avalanche — same (seed, index) always yields the same value.\n * THE replacement for Math.random in custom scenes.\n */\nexport function rand01(seed: string | number, index = 0): number {\n let h = 2166136261 >>> 0;\n const str = String(seed);\n for (let i = 0; i < str.length; i++) {\n h ^= str.charCodeAt(i);\n h = Math.imul(h, 16777619) >>> 0;\n }\n h = (h + Math.imul(index + 1, 0x9e3779b9)) >>> 0;\n h ^= h >>> 16;\n h = Math.imul(h, 0x85ebca6b) >>> 0;\n h ^= h >>> 13;\n h = Math.imul(h, 0xc2b2ae35) >>> 0;\n h ^= h >>> 16;\n return (h >>> 0) / 4294967296;\n}\n\nexport interface ParticlesOptions {\n /** \"float\" ambient bobbing · \"rise\" upward stream · \"rain\" downward. Default \"float\". */\n mode?: \"float\" | \"rise\" | \"rain\";\n /** Travel speed multiplier. Default 1. */\n speed?: number;\n /** Particle size range in px (scale with sceneScale at call site). Default [4, 10]. */\n sizeRange?: readonly [number, number];\n}\n\nexport interface Particle {\n /** Position as 0..1 fractions of the field — multiply by width/height. */\n x: number;\n y: number;\n /** Size in px (from sizeRange). */\n size: number;\n /** 0..1 — already fades near field edges, no popping. */\n opacity: number;\n /** Degrees. */\n rotation: number;\n}\n\n/**\n * Seeded ambient particle field. Deterministic: same (count, seed, progress)\n * → identical field. Map to absolutely-positioned divs:\n *\n * particles(progress, 24, \"stars\").map((p, i) => (\n * <div key={i} style={{ position: \"absolute\", left: p.x * width, top: p.y * height,\n * width: p.size, height: p.size, borderRadius: \"50%\", background: accent, opacity: p.opacity }} />\n * ))\n */\nexport function particles(\n progress: number,\n count: number,\n seed: string | number,\n options?: ParticlesOptions,\n): Particle[] {\n const { mode = \"float\", speed = 1, sizeRange = [4, 10] } = options ?? {};\n const p = clamp01(progress);\n const out: Particle[] = [];\n for (let i = 0; i < count; i++) {\n const baseX = rand01(seed, i * 7);\n const baseY = rand01(seed, i * 7 + 1);\n const sizeT = rand01(seed, i * 7 + 2);\n const phase0 = rand01(seed, i * 7 + 3);\n const wobble = 0.015 + rand01(seed, i * 7 + 4) * 0.025;\n const x = baseX + Math.sin((p * speed + phase0) * Math.PI * 3) * wobble;\n let y: number;\n if (mode === \"rise\") y = (((baseY - p * speed * 0.35) % 1) + 1) % 1;\n else if (mode === \"rain\") y = (baseY + p * speed * 0.35) % 1;\n else y = baseY + Math.sin((p * speed + phase0) * Math.PI * 2) * wobble * 1.5;\n const edgeFade = clamp01(Math.sin(Math.PI * clamp01(y)) * 2);\n out.push({\n x,\n y,\n size: sizeRange[0] + (sizeRange[1] - sizeRange[0]) * sizeT,\n opacity: edgeFade * (0.45 + 0.55 * rand01(seed, i * 7 + 5)),\n rotation: (phase0 * 360 + p * speed * 240) % 360,\n });\n }\n return out;\n}\n\nexport interface BurstOptions {\n /** Cone width in degrees. Default 360 (full radial burst). */\n spreadDeg?: number;\n /** Cone center direction in degrees, -90 = up. Default -90. */\n originDeg?: number;\n /** Downward pull applied over time (0 = none). Default 0.35. */\n gravity?: number;\n easing?: EasingFn;\n}\n\nexport interface BurstParticle {\n /** Offset from origin in UNIT radius — multiply by px radius at call site. */\n dx: number;\n dy: number;\n rotation: number;\n scale: number;\n opacity: number;\n}\n\n/**\n * Seeded radial burst (confetti / celebration). Particles fly outward from a\n * shared origin as progress runs 0→1, with gravity and a fade tail.\n *\n * burst(phase(progress, [0.55, 1]), 32, \"confetti\").map((b, i) => (\n * <div key={i} style={{ position: \"absolute\",\n * left: width / 2 + b.dx * minDim * 0.42, top: height * 0.45 + b.dy * minDim * 0.42,\n * width: 12, height: 12, background: i % 2 ? accent : secondary,\n * opacity: b.opacity, transform: `rotate(${b.rotation}deg) scale(${b.scale})` }} />\n * ))\n */\nexport function burst(\n progress: number,\n count: number,\n seed: string | number,\n options?: BurstOptions,\n): BurstParticle[] {\n const { spreadDeg = 360, originDeg = -90, gravity = 0.35, easing = EASE.outCubic } = options ?? {};\n const e = easing(clamp01(progress));\n const out: BurstParticle[] = [];\n for (let i = 0; i < count; i++) {\n const jitter = rand01(seed, i * 5) * 0.8;\n const angleDeg = originDeg - spreadDeg / 2 + (spreadDeg * (i + jitter)) / Math.max(1, count);\n const rad = (angleDeg * Math.PI) / 180;\n const dist = e * (0.5 + 0.5 * rand01(seed, i * 5 + 1));\n out.push({\n dx: Math.cos(rad) * dist,\n dy: Math.sin(rad) * dist + gravity * e * e,\n rotation: (rand01(seed, i * 5 + 2) * 720 * e) % 360,\n scale: 1 - 0.45 * e * rand01(seed, i * 5 + 3),\n opacity: e < 0.7 ? 1 : clamp01((1 - e) / 0.3),\n });\n }\n return out;\n}\n"
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
"path": "src/visual-system/motion/curves.ts",
|
|
25
|
-
"type": "registry:lib",
|
|
26
|
-
"target": "vanillasky/motion/curves.ts",
|
|
27
|
-
"content": "/**\n * Animation utilities — interpolate, spring, easing.\n * Pure math functions, no external dependencies.\n * Inspired by Remotion's API but fully independent.\n */\n\n// ─── Easing functions ────────────────────────────────────────────\n\n/**\n * CSS-style cubic-bezier curve → easing function (t: 0..1 → eased value).\n * Deterministic Newton-Raphson with bisection fallback — same approach as\n * the browser's cubic-bezier(). y values outside [0, 1] produce overshoot.\n *\n * docs/motion-library.md §1 references curves as `Easing.bezier(...)` —\n * this is that function (also exposed as `Easing.bezier`).\n */\nexport function cubicBezier(\n x1: number,\n y1: number,\n x2: number,\n y2: number,\n): (t: number) => number {\n const cx = 3 * x1;\n const bx = 3 * (x2 - x1) - cx;\n const ax = 1 - cx - bx;\n const cy = 3 * y1;\n const by = 3 * (y2 - y1) - cy;\n const ay = 1 - cy - by;\n const sampleX = (t: number) => ((ax * t + bx) * t + cx) * t;\n const sampleY = (t: number) => ((ay * t + by) * t + cy) * t;\n const sampleDX = (t: number) => (3 * ax * t + 2 * bx) * t + cx;\n return (x: number): number => {\n if (x <= 0) return 0;\n if (x >= 1) return 1;\n let t = x;\n for (let i = 0; i < 8; i++) {\n const err = sampleX(t) - x;\n if (Math.abs(err) < 1e-6) return sampleY(t);\n const d = sampleDX(t);\n if (Math.abs(d) < 1e-6) break;\n t -= err / d;\n }\n // Bisection fallback — sampleX is monotonic on [0, 1] for valid curves.\n let lo = 0;\n let hi = 1;\n t = x;\n for (let i = 0; i < 24; i++) {\n const v = sampleX(t);\n if (Math.abs(v - x) < 1e-6) break;\n if (v < x) lo = t;\n else hi = t;\n t = (lo + hi) / 2;\n }\n return sampleY(t);\n };\n}\n\nexport const Easing = {\n /** Easing.bezier(0.16, 1, 0.3, 1) — the canonical curve syntax from docs/motion-library.md. */\n bezier: cubicBezier,\n // Curves\n linear: (t: number) => t,\n quad: (t: number) => t * t,\n cubic: (t: number) => t * t * t,\n sin: (t: number) => 1 - Math.cos((t * Math.PI) / 2),\n exp: (t: number) => (t === 0 ? 0 : Math.pow(2, 10 * (t - 1))),\n circle: (t: number) => 1 - Math.sqrt(1 - t * t),\n\n // Directional modifiers\n in: (fn: (t: number) => number) => fn,\n out:\n (fn: (t: number) => number) =>\n (t: number) =>\n 1 - fn(1 - t),\n inOut:\n (fn: (t: number) => number) =>\n (t: number) =>\n t < 0.5 ? fn(t * 2) / 2 : 1 - fn((1 - t) * 2) / 2,\n} as const;\n\n// ─── Interpolate ─────────────────────────────────────────────────\n\ninterface InterpolateOptions {\n easing?: (t: number) => number;\n extrapolateLeft?: \"clamp\" | \"extend\";\n extrapolateRight?: \"clamp\" | \"extend\";\n}\n\n/**\n * Maps a value from one range to another with optional easing and clamping.\n *\n * @example\n * interpolate(0.5, [0, 1], [0, 100]) // 50\n * interpolate(progress, [0, 0.7], [0, 10000], { extrapolateRight: \"clamp\" })\n */\nexport function interpolate(\n value: number,\n inputRange: readonly [number, ...number[]],\n outputRange: readonly [number, ...number[]],\n options?: InterpolateOptions,\n): number {\n const { easing, extrapolateLeft = \"extend\", extrapolateRight = \"extend\" } = options ?? {};\n\n // Find the segment\n let i = 0;\n for (; i < inputRange.length - 2; i++) {\n if (value < inputRange[i + 1]) break;\n }\n\n const inputMin = inputRange[i];\n const inputMax = inputRange[i + 1];\n const outputMin = outputRange[i];\n const outputMax = outputRange[i + 1];\n\n // Normalize to 0-1\n let t = inputMax === inputMin ? 0 : (value - inputMin) / (inputMax - inputMin);\n\n // Clamp\n if (t < 0 && extrapolateLeft === \"clamp\") t = 0;\n if (t > 1 && extrapolateRight === \"clamp\") t = 1;\n\n // Apply easing\n if (easing && t >= 0 && t <= 1) {\n t = easing(t);\n }\n\n return outputMin + t * (outputMax - outputMin);\n}\n\n// ─── Spring ──────────────────────────────────────────────────────\n\nexport interface SpringConfig {\n damping?: number;\n stiffness?: number;\n mass?: number;\n}\n\n// Presets\nexport const SPRING_SMOOTH: SpringConfig = { damping: 26, stiffness: 170 };\nexport const SPRING_SNAPPY: SpringConfig = { damping: 20, stiffness: 300 };\nexport const SPRING_BOUNCY: SpringConfig = { damping: 10, stiffness: 180 };\n// Lower damping than SNAPPY + higher stiffness → subtle overshoot, quick settle.\n// Sits between SNAPPY (no overshoot) and BOUNCY (lots) for a \"punchy but not silly\" feel.\nexport const SPRING_CRISP: SpringConfig = { damping: 14, stiffness: 320 };\n\n/**\n * Spring physics simulation. Converts linear progress (0→1) to\n * spring-eased progress with overshoot and settle.\n *\n * @param progress - Linear progress 0→1\n * @param config - Spring physical properties\n * @returns Spring-eased value (may overshoot 1 with low damping)\n *\n * @example\n * const scale = spring(progress, SPRING_BOUNCY); // bouncy entrance\n * const opacity = spring(progress, SPRING_SMOOTH); // smooth fade\n */\nexport function spring(progress: number, config?: SpringConfig): number {\n if (progress <= 0) return 0;\n if (progress >= 1) {\n // For high-damping (no overshoot) configs, settle at 1\n const { damping = 26 } = config ?? {};\n if (damping >= 20) return 1;\n }\n\n const { damping = 26, stiffness = 170, mass = 1 } = config ?? {};\n\n // Simulate spring physics at the given progress point\n // We run a fixed number of iterations to find the spring value\n const omega = Math.sqrt(stiffness / mass);\n const zeta = damping / (2 * Math.sqrt(stiffness * mass));\n\n // Scale progress to a time value (spring needs ~1-3s to settle)\n const t = progress * 3.5;\n\n let value: number;\n\n if (zeta < 1) {\n // Underdamped (bouncy)\n const omegaD = omega * Math.sqrt(1 - zeta * zeta);\n value = 1 - Math.exp(-zeta * omega * t) * (Math.cos(omegaD * t) + (zeta * omega / omegaD) * Math.sin(omegaD * t));\n } else if (zeta === 1) {\n // Critically damped\n value = 1 - Math.exp(-omega * t) * (1 + omega * t);\n } else {\n // Overdamped\n const s1 = -omega * (zeta + Math.sqrt(zeta * zeta - 1));\n const s2 = -omega * (zeta - Math.sqrt(zeta * zeta - 1));\n value = 1 + (s1 * Math.exp(s2 * t) - s2 * Math.exp(s1 * t)) / (s2 - s1);\n }\n\n return value;\n}\n\n// ─── Stagger helper ──────────────────────────────────────────────\n\n/**\n * Returns a progress value for a staggered animation item.\n * Maps overall progress to per-item progress with delay.\n *\n * @param progress - Overall scene progress (0→1)\n * @param index - Item index (0-based)\n * @param total - Total number of items\n * @param staggerDelay - Delay between items (0→1 scale, default 0.08)\n * @param startAt - When the first item should start appearing (default 0.1)\n *\n * @example\n * items.map((item, i) => {\n * const itemProgress = stagger(progress, i, items.length);\n * const opacity = spring(itemProgress, SPRING_SMOOTH);\n * return <div style={{ opacity }}>{item}</div>;\n * })\n */\nexport function stagger(\n progress: number,\n index: number,\n total: number,\n staggerDelay = 0.08,\n startAt = 0.1,\n): number {\n const itemStart = startAt + index * staggerDelay;\n const available = 1 - startAt - (total - 1) * staggerDelay;\n const itemDuration = Math.max(0.1, available);\n return Math.max(0, Math.min(1, (progress - itemStart) / itemDuration));\n}\n"
|
|
28
|
-
}
|
|
29
|
-
],
|
|
30
|
-
"meta": {
|
|
31
|
-
"vanillasky": {
|
|
32
|
-
"layer": "lib",
|
|
33
|
-
"tier": "free",
|
|
34
|
-
"domain": "motion",
|
|
35
|
-
"level": "foundation",
|
|
36
|
-
"audiences": [
|
|
37
|
-
"custom-scenes",
|
|
38
|
-
"react-developers"
|
|
39
|
-
],
|
|
40
|
-
"dependencies": [],
|
|
41
|
-
"useWhen": "Use it whenever a value changes with scene progress; start with a composed helper, then drop to interpolation or springs when the scene needs custom choreography.",
|
|
42
|
-
"avoidWhen": "Do not use browser timers, CSS transitions, requestAnimationFrame, or Math.random inside a renderable scene."
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
-
"name": "quote",
|
|
4
|
-
"type": "registry:block",
|
|
5
|
-
"title": "Quote",
|
|
6
|
-
"description": "A short exact quotation with a legible attribution over relevant media or black.",
|
|
7
|
-
"dependencies": [
|
|
8
|
-
"react"
|
|
9
|
-
],
|
|
10
|
-
"registryDependencies": [
|
|
11
|
-
"@vanillasky/backgrounds",
|
|
12
|
-
"@vanillasky/motion",
|
|
13
|
-
"@vanillasky/template-context",
|
|
14
|
-
"@vanillasky/theme"
|
|
15
|
-
],
|
|
16
|
-
"files": [
|
|
17
|
-
{
|
|
18
|
-
"path": "src/visual-system/scene-templates/quote.tsx",
|
|
19
|
-
"type": "registry:component",
|
|
20
|
-
"target": "vanillasky/scene-templates/quote.tsx",
|
|
21
|
-
"content": "import {EditorialSurface} from './editorial-background';\nimport {hasSceneMedia} from './media-source';\nimport type {SceneTemplateProps} from './types';\nimport {fade} from './editorial-typography';\n\n/** Exact words with a readable attribution; no decorative oversized quotation mark. */\nfunction QuoteScene(props:SceneTemplateProps){\n const {variables,width,height,progress,motionProgress=progress,safeZone}=props;\n const overMedia=hasSceneMedia(variables);\n const u=Math.min(width,height),top=Math.max(height*.16,safeZone.top),bottom=height-Math.max(height*.23,safeZone.bottom);\n const side=Math.max(width*.13,safeZone.left,safeZone.right);\n return <EditorialSurface {...props} template=\"quote\">\n <div style={{position:'absolute',left:side,top:(top+bottom)/2,width:width-side*2,transform:'translateY(-50%)',textAlign:'center',opacity:fade((motionProgress-.03)/.24)}}>\n <div style={{fontSize:u*.067,fontWeight:500,lineHeight:1.25,letterSpacing:'-.025em',textWrap:'balance',overflowWrap:'anywhere'}}>“{String(variables.quote??'')}”</div>\n <div style={{fontSize:u*.041,fontWeight:400,color:overMedia?'#fff':'#b7b7bc',lineHeight:1.3,marginTop:u*.065,textWrap:'balance',overflowWrap:'anywhere'}}>{String(variables.attribution??'')}</div>\n </div>\n </EditorialSurface>;\n}\n\nexport const QuoteSceneTemplate = QuoteScene;\n"
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
"path": "src/visual-system/scene-templates/types.ts",
|
|
25
|
-
"type": "registry:component",
|
|
26
|
-
"target": "vanillasky/scene-templates/types.ts",
|
|
27
|
-
"content": "/**\n * Scene template types.\n *\n * A template is a reusable React component that defines how a scene looks.\n * It declares the variables an editor or model may provide\n * and receives universal settings as props.\n *\n * Templates are searchable by AI via description, category, jobs, register,\n * and useWhen guidance.\n * The variable schema enables any LLM to fill in template variables via JSON.\n */\n\nimport type { ResolvedTokens } from \"../theme\";\nimport type { SafeZone, TemplateStyle } from \"../template-context\";\n\n/**\n * Props passed to every scene template component.\n *\n * All animation must be driven by `progress` (0→1). No CSS animations,\n * no Framer Motion, no requestAnimationFrame. Use interpolate/spring\n * from animation-utils.ts.\n *\n * Scale factor: use `Math.min(width, height) / 1080` — normalizes to\n * the short edge so visuals are consistent across portrait and landscape.\n */\nexport interface SceneTemplateProps {\n variables: Record<string, unknown>;\n style: TemplateStyle;\n /** 0→1 through the scene's duration */\n progress: number;\n /** Presentation clock. Active templates receive the same complete 0→1 timeline as progress. */\n motionProgress?: number;\n /** 0→1 beat pulse intensity */\n beatIntensity: number;\n /** 1080 (portrait) or 1920 (landscape) */\n width: number;\n /** 1920 (portrait) or 1080 (landscape) */\n height: number;\n /** Video-level default text effect (for templates that opt in via usesGlobalTextEffect) */\n textArchetype?: string;\n /** How text leaves the scene (fade / shrink / pop / blur-scale). Falls back to a sensible default per textArchetype when undefined. */\n /** Video-level default background effect (for templates that opt in via usesGlobalBackgroundEffect) */\n backgroundEffect?: string;\n /** Platform-aware safe zone insets in pixels — use for text placement */\n safeZone: SafeZone;\n /** Scene duration in seconds — use for time-based (not progress-based) animations */\n sceneDuration?: number;\n /**\n * Brand tokens already resolved from `style`. Built-in templates import\n * resolveTokens directly; an ejected `custom_*` scene can't import anything,\n * so without this it has no way to reach the same values and ends up\n * hardcoding white, black and shadows — the body then looks generic next to\n * a frame that IS using the brand.\n */\n tokens?: ResolvedTokens;\n /**\n * True when the preview player is actively advancing progress; false when paused.\n * Templates that play HTML5 <video> elements should pause them when this is false.\n * Undefined (export capture path) is treated as true.\n */\n isPlaying?: boolean;\n}\n"
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
"path": "src/visual-system/scene-templates/editorial-typography.ts",
|
|
31
|
-
"type": "registry:component",
|
|
32
|
-
"target": "vanillasky/scene-templates/editorial-typography.ts",
|
|
33
|
-
"content": "/** Shared typography for quiet editorial scenes. */\nexport const editorialFont = '-apple-system, BlinkMacSystemFont, \"Helvetica Neue\", Roboto, Arial, sans-serif';\nexport const fade = (value: number) => { const t = Math.max(0, Math.min(1, value)); return t * t * (3 - 2 * t); };\n\nexport const editorialLabel = (unit: number) => ({fontSize:unit*.042,fontWeight:400,color:'#b7b7bc',lineHeight:1.25} as const);\n"
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
"path": "src/visual-system/scene-templates/editorial-background.tsx",
|
|
37
|
-
"type": "registry:component",
|
|
38
|
-
"target": "vanillasky/scene-templates/editorial-background.tsx",
|
|
39
|
-
"content": "import type {CSSProperties, ReactNode} from 'react';\nimport type {SceneTemplateProps} from './types';\nimport {SceneBackground, getMediaBackgroundProps, hasSceneMedia} from './scene-background';\nimport {editorialFont} from './editorial-typography';\n\n/** Optional host-resolved footage, sharing the player's decode and playback plane. */\nexport function EditorialSurface({children,template,...props}:SceneTemplateProps & {children:ReactNode;template:string}) {\n const {variables,style,width,height,progress,sceneDuration,isPlaying}=props;\n const overMedia=hasSceneMedia(variables),u=Math.min(width,height);\n const surface:CSSProperties={position:'absolute',inset:0,overflow:'hidden',background:overMedia?'var(--vanillasky-template-surface, #000)':'#000',fontFamily:editorialFont,color:'#fff',\n // Two soft, scale-aware shadows preserve local contrast without outlining type.\n textShadow:overMedia?`0 ${u*.002}px ${u*.008}px rgba(0,0,0,.65), 0 ${u*.006}px ${u*.016}px rgba(0,0,0,.4)`:undefined};\n return <div data-template={template} style={surface}>\n {overMedia&&<SceneBackground style={style} width={width} height={height} progress={progress} sceneDuration={sceneDuration} {...getMediaBackgroundProps(variables)} mediaTreatment=\"cinematic\" textAnchor=\"center\" backgroundEffect=\"none\" isPlaying={isPlaying}/>}\n {children}\n </div>;\n}\n"
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
"path": "src/visual-system/scene-templates/scene-background.tsx",
|
|
43
|
-
"type": "registry:component",
|
|
44
|
-
"target": "vanillasky/scene-templates/scene-background.tsx",
|
|
45
|
-
"content": "/**\n * Shared photo/video backdrop for built-in and customer-owned templates.\n * Media is host-resolved; this component never searches or generates assets.\n * The base is fixed black. Photos and videos cover it when available, and\n * optional scrims appear only once the media can paint. The cinematic built-ins\n * request no scrim; custom templates can choose a treatment and text anchor.\n * The retained internal \"gradient\" media sentinel selects the black base and\n * ignores mediaUrl. It is not a built-in authoring mode or brand-color control.\n * Video playback can be owned by the player's persistent external backdrop.\n */\n\nimport React, { useEffect, useState } from \"react\";\nimport {\n hasSceneMedia,\n resolveMediaType,\n type ResolvedMediaType,\n} from \"./media-source\";\nimport type { TemplateStyle } from \"../template-context\";\nimport { BrandGradientOverlay } from \"../backgrounds\";\nimport { getBackgroundTransform } from \"../backgrounds\";\nimport { useExternalVideoBackdrop } from \"./external-video-backdrop\";\nimport { resolveMediaPosition, type MediaPosition } from \"./media-position\";\nimport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\n\nexport { hasSceneMedia, resolveMediaType };\nexport { resolveMediaPosition } from \"./media-position\";\nexport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\nexport type { ResolvedMediaType };\n\nexport type { MediaPosition };\nexport type MediaTreatment = \"none\" | \"subtle\" | \"cinematic\" | \"text-safe\";\n\nexport function resolveMediaTreatment(value: string): MediaTreatment {\n return value === \"none\" || value === \"subtle\" || value === \"text-safe\" ? value : \"cinematic\";\n}\n\nexport interface MediaTreatmentLayer {\n id: \"vignette\" | \"center-scrim\" | \"bottom-scrim\";\n background: string;\n style?: React.CSSProperties;\n}\n\n/**\n * Where the template puts its type. The scrim is shaped to the copy, not to\n * the frame: darkening picture the type never touches costs contrast in the\n * photo and buys no legibility. \"full\" is the conservative default for\n * templates that have not declared an anchor.\n */\nexport type MediaTextAnchor = \"center\" | \"bottom\" | \"full\";\n\n/**\n * Smoothstep-sampled alpha stops between `start`% and `end`% of the gradient\n * box, held at full strength before `start` and after `end`.\n *\n * A two-stop `rgba(0,0,0,a) → transparent` scrim ramps alpha linearly, so it\n * ends with a constant slope. Lateral inhibition in the eye amplifies that\n * slope discontinuity into a visible band — the grey bar cutting across the\n * frame that makes an overlay read as an overlay. Smoothstep flattens the\n * curve at both ends, so the scrim holds where the type sits and then leaves\n * without an edge: the same peak coverage over the copy, noticeably less of\n * the picture spent getting there.\n */\nconst SCRIM_STOP_COUNT = 7;\n\nfunction smoothstep(t: number): number {\n return t * t * (3 - 2 * t);\n}\n\nfunction easedStops(\n peakAlpha: number,\n start: number,\n end: number,\n direction: \"fade-out\" | \"fade-in\",\n): string {\n const alphaAt = (t: number): string => {\n const eased = direction === \"fade-out\" ? 1 - smoothstep(t) : smoothstep(t);\n return `rgba(0,0,0,${Number((peakAlpha * eased).toFixed(3))})`;\n };\n const stops: string[] = [];\n if (start > 0) stops.push(`${alphaAt(0)} 0%`);\n for (let i = 0; i < SCRIM_STOP_COUNT; i += 1) {\n const t = i / (SCRIM_STOP_COUNT - 1);\n const position = Number((start + (end - start) * t).toFixed(2));\n stops.push(`${alphaAt(t)} ${position}%`);\n }\n if (end < 100) stops.push(`${alphaAt(1)} 100%`);\n return stops.join(\", \");\n}\n\n/**\n * Export-safe contrast recipes. Overlays only: SVG capture cannot rely on CSS\n * filters, so a blur-behind-text plate is off the table.\n *\n * The scrims deliberately stop short of solving legibility on their own. A\n * uniform darkening strong enough to carry white type over a blown-out sky\n * needs roughly 0.8 alpha — at that point the photo is a texture, not a\n * picture. The cheaper half of the job belongs to the type: a per-glyph halo\n * (MEDIA_TEXT_SHADOW) buys local contrast exactly where it is needed and\n * costs the image nothing. Scrim for the plate, halo for the glyph.\n */\nexport function getMediaTreatmentLayers(\n value: string,\n anchor: MediaTextAnchor = \"full\",\n): MediaTreatmentLayer[] {\n const treatment = resolveMediaTreatment(value);\n // Nothing over the picture at all. Every other treatment exists to carry\n // type across a photograph; where the scene has no type - a generated clip\n // that is the whole point of the beat - even a vignette is something the\n // viewer did not ask to look through.\n if (treatment === \"none\") return [];\n const vignette: MediaTreatmentLayer = {\n id: \"vignette\",\n background:\n treatment === \"subtle\"\n ? `radial-gradient(ellipse at center, ${easedStops(0.28, 45, 100, \"fade-in\")})`\n : `radial-gradient(ellipse at center, ${easedStops(0.72, 32, 100, \"fade-in\")})`,\n };\n if (treatment === \"subtle\") return [vignette];\n\n const textSafe = treatment === \"text-safe\";\n const layers: MediaTreatmentLayer[] = [vignette];\n\n if (anchor !== \"bottom\") {\n layers.push({\n id: \"center-scrim\",\n background: textSafe\n ? `radial-gradient(ellipse 92% 58% at 50% 50%, ${easedStops(0.46, 34, 90, \"fade-out\")})`\n : `radial-gradient(ellipse 88% 52% at 50% 50%, ${easedStops(0.26, 30, 88, \"fade-out\")})`,\n });\n }\n\n if (anchor !== \"center\") {\n layers.push({\n id: \"bottom-scrim\",\n background: `linear-gradient(to top, ${easedStops(textSafe ? 0.64 : 0.5, 8, 100, \"fade-out\")})`,\n style: { top: \"55%\" },\n });\n }\n\n return layers;\n}\n\n/**\n * Whether the backdrop is actually painting, which is what decides if a scrim\n * is earned. \"pending\" is a browser-only state: static and export renders\n * never run effects and never wait on a network, so they start (and stay)\n * ready and their output is unchanged.\n */\ntype MediaPaintState = \"pending\" | \"ready\" | \"failed\";\n\nfunction initialMediaPaint(\n wantsMedia: boolean,\n resolved: ResolvedMediaType,\n mediaUrl: string,\n mediaPoster: string | undefined,\n): MediaPaintState {\n if (typeof window === \"undefined\") return \"ready\";\n if (!wantsMedia) return \"ready\";\n // A poster paints the video's frame immediately, so the scene is already\n // showing footage even though the stream is still decoding.\n if (resolved === \"video\") return mediaPoster ? \"ready\" : \"pending\";\n if (typeof Image === \"undefined\") return \"ready\";\n // Preloaded or browser-cached media decodes synchronously. Reporting it\n // ready on the first render keeps the common mid-playback case free of a\n // black-then-photo flicker.\n const cached = new Image();\n cached.src = mediaUrl;\n return cached.complete && cached.naturalWidth > 0 ? \"ready\" : \"pending\";\n}\n\nexport function getMediaBackgroundProps(variables: Record<string, unknown>) {\n return {\n mediaUrl: String(variables.mediaUrl || \"\"),\n mediaType: String(variables.mediaType || \"auto\"),\n mediaPoster: String(variables.mediaPoster || \"\"),\n mediaPosition: String(variables.mediaPosition || \"center\"),\n mediaTreatment: String(variables.mediaTreatment || \"cinematic\"),\n };\n}\n\nexport interface SceneBackgroundProps {\n style: TemplateStyle;\n progress: number;\n sceneDuration?: number;\n width: number;\n height: number;\n mediaUrl?: string;\n mediaType?: string;\n /** Still image URL shown while the <video> backdrop decodes its first\n * frame. Without it the element renders transparent during the\n * decode window and the black base shows through. */\n mediaPoster?: string;\n /** Cover-crop focal anchor. Keeps the important edge/subject visible. */\n mediaPosition?: string;\n /** Overlay recipe: subtle, cinematic, or stronger text-safe contrast. */\n mediaTreatment?: string;\n /** Where this template's copy sits, so the scrim is shaped to the type\n * instead of to the frame. Defaults to \"full\" (scrim both the middle and\n * the lower third) for templates that have not declared an anchor. */\n textAnchor?: MediaTextAnchor;\n /** Background motion effect (drift / pulse / Ken Burns). Applied to the photo/video. */\n backgroundEffect?: string;\n /** Retained seed input for customer-owned backdrop compositions. */\n seed?: number | string;\n /** Pause video when preview is paused. Defaults to true (export path). */\n isPlaying?: boolean;\n beatIntensity?: number;\n}\n\nexport const SceneBackground: React.FC<SceneBackgroundProps> = ({\n style,\n progress,\n sceneDuration,\n width: _width, // accepted for symmetry; not currently used in render\n height: _height,\n mediaUrl = \"\",\n mediaType = \"auto\",\n mediaPoster,\n mediaPosition = \"center\",\n mediaTreatment = \"cinematic\",\n textAnchor = \"full\",\n backgroundEffect,\n seed,\n isPlaying = true,\n beatIntensity = 0,\n}) => {\n void _width;\n void _height;\n const resolved = resolveMediaType(mediaType, mediaUrl);\n const wantsMedia = resolved !== \"gradient\" && !!mediaUrl;\n const externalVideoBackdrop = useExternalVideoBackdrop();\n const hasExternalVideoBackdrop = externalVideoBackdrop !== false && resolved === \"video\";\n const externalVideoFailed = externalVideoBackdrop === \"fallback\" && resolved === \"video\";\n const externalVideoReady = externalVideoBackdrop === \"ready\" && resolved === \"video\";\n\n // Apply picture and scrim together. A loading or failed asset keeps the\n // fixed black base instead of painting contrast treatment over empty media.\n const [mediaPaint, setMediaPaint] = useState<MediaPaintState>(() =>\n initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster),\n );\n useEffect(() => {\n setMediaPaint(initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster));\n // Video reports its own paint through onLoadedData / onError below.\n if (!wantsMedia || resolved !== \"photo\") return;\n if (typeof Image === \"undefined\") return;\n let cancelled = false;\n const probe = new Image();\n probe.onload = () => {\n if (!cancelled) setMediaPaint(\"ready\");\n };\n probe.onerror = () => {\n if (!cancelled) setMediaPaint(\"failed\");\n };\n probe.src = mediaUrl;\n if (probe.complete) setMediaPaint(probe.naturalWidth > 0 ? \"ready\" : \"failed\");\n return () => {\n cancelled = true;\n probe.onload = null;\n probe.onerror = null;\n };\n }, [mediaUrl, mediaPoster, resolved, wantsMedia]);\n\n // The element stays mounted while pending — that is what loads it. Only a\n // confirmed failure takes it back out.\n const showMedia = wantsMedia && mediaPaint !== \"failed\";\n const showTreatment = wantsMedia && mediaPaint === \"ready\";\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const resolvedTreatment = resolveMediaTreatment(mediaTreatment);\n const treatmentLayers = getMediaTreatmentLayers(resolvedTreatment, textAnchor);\n\n const gradSeed =\n typeof seed === \"number\"\n ? seed\n : typeof seed === \"string\"\n ? seed.split(\"\").reduce((acc, c) => acc + c.charCodeAt(0), 0)\n : 0;\n\n const bgTransform = getBackgroundTransform(\n backgroundEffect,\n progress,\n beatIntensity,\n );\n\n return (\n <>\n {(!hasExternalVideoBackdrop || externalVideoFailed) && (\n <BrandGradientOverlay\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n seed={gradSeed}\n />\n )}\n\n {showMedia && !hasExternalVideoBackdrop &&\n (resolved === \"video\" ? (\n <SceneVideoBackdrop\n mediaUrl={mediaUrl}\n mediaPoster={mediaPoster}\n mediaPosition={mediaPosition}\n backgroundEffect={backgroundEffect}\n progress={progress}\n sceneDuration={sceneDuration}\n beatIntensity={beatIntensity}\n isPlaying={isPlaying}\n onReady={() => setMediaPaint(\"ready\")}\n onError={() => setMediaPaint(\"failed\")}\n />\n ) : (\n <img\n src={mediaUrl}\n alt=\"\"\n aria-hidden=\"true\"\n draggable={false}\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: resolvedPosition,\n }}\n />\n ))}\n\n {(hasExternalVideoBackdrop ? externalVideoReady : showTreatment) && !externalVideoFailed &&\n treatmentLayers.map((layer) => (\n <div\n key={layer.id}\n data-media-treatment={resolvedTreatment}\n data-media-overlay={layer.id}\n style={{\n position: \"absolute\",\n inset: 0,\n background: layer.background,\n pointerEvents: \"none\",\n ...layer.style,\n }}\n />\n ))}\n </>\n );\n};\n"
|
|
46
|
-
},
|
|
47
|
-
{
|
|
48
|
-
"path": "src/visual-system/scene-templates/media-source.ts",
|
|
49
|
-
"type": "registry:component",
|
|
50
|
-
"target": "vanillasky/scene-templates/media-source.ts",
|
|
51
|
-
"content": "/**\n * How a scene's backdrop resolves from its variables. Pure, React-free, and\n * deliberately a leaf module: the player's media preloader shares it so the\n * question \"is this scene backed by a photo, a video, or the brand gradient?\"\n * has exactly one answer in the codebase.\n */\n\nconst VIDEO_EXTENSIONS = [\".mp4\", \".webm\", \".mov\", \".m4v\", \".avi\"];\n\nfunction isVideoUrl(url: string): boolean {\n try {\n const pathname = new URL(url).pathname.toLowerCase();\n return VIDEO_EXTENSIONS.some((ext) => pathname.endsWith(ext));\n } catch {\n const lower = url.toLowerCase();\n return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext));\n }\n}\n\nexport type ResolvedMediaType = \"photo\" | \"video\" | \"gradient\";\n\n/** Apple mobile browsers share WebKit's conservative video-decoder ceiling. */\nexport function limitsConcurrentVideoDecoders(): boolean {\n if (typeof navigator === \"undefined\") return false;\n return /iPad|iPhone|iPod/.test(navigator.userAgent) ||\n (navigator.platform === \"MacIntel\" && navigator.maxTouchPoints > 1);\n}\n\nexport function resolveMediaType(\n mediaType: string,\n mediaUrl: string,\n): ResolvedMediaType {\n if (mediaType === \"gradient\") return \"gradient\";\n if (mediaType === \"video\") return \"video\";\n if (mediaType === \"photo\") return \"photo\";\n // \"auto\" — detect from URL extension\n return mediaUrl && isVideoUrl(mediaUrl) ? \"video\" : \"photo\";\n}\n\n/**\n * True when the scene actually renders a photo or video backdrop — i.e. a\n * mediaUrl is set and the template has not been pinned to the brand gradient.\n * Templates use it to switch their type onto the media legibility recipe.\n */\nexport function hasSceneMedia(variables: Record<string, unknown>): boolean {\n return (\n String(variables.mediaUrl || \"\").trim() !== \"\" &&\n String(variables.mediaType || \"auto\") !== \"gradient\"\n );\n}\n"
|
|
52
|
-
}
|
|
53
|
-
],
|
|
54
|
-
"meta": {
|
|
55
|
-
"vanillasky": {
|
|
56
|
-
"layer": "template",
|
|
57
|
-
"tier": "free",
|
|
58
|
-
"label": "Quote",
|
|
59
|
-
"description": "A short exact quotation with a legible attribution over relevant media or black.",
|
|
60
|
-
"usesGlobalTextEffect": false,
|
|
61
|
-
"usesGlobalTransition": false,
|
|
62
|
-
"usesGlobalBackgroundEffect": false,
|
|
63
|
-
"textCanvas": "tight",
|
|
64
|
-
"family": "Explainers",
|
|
65
|
-
"jobs": [
|
|
66
|
-
"proof"
|
|
67
|
-
],
|
|
68
|
-
"register": "motion-led",
|
|
69
|
-
"useWhen": "A supplied quotation deserves its own moment. Use exact source words and attribution only.",
|
|
70
|
-
"avoidWhen": "No exact quotation or reliable attribution is supplied; never invent testimony.",
|
|
71
|
-
"minDuration": 5,
|
|
72
|
-
"preferredDuration": 7,
|
|
73
|
-
"timing": {
|
|
74
|
-
"contentFields": [
|
|
75
|
-
"quote",
|
|
76
|
-
"attribution"
|
|
77
|
-
],
|
|
78
|
-
"contentUnit": "words",
|
|
79
|
-
"revealSeconds": 1.2,
|
|
80
|
-
"holdSeconds": 3,
|
|
81
|
-
"exitSeconds": 0
|
|
82
|
-
},
|
|
83
|
-
"schema": {
|
|
84
|
-
"type": "object",
|
|
85
|
-
"properties": {
|
|
86
|
-
"quote": {
|
|
87
|
-
"type": "string",
|
|
88
|
-
"minLength": 1,
|
|
89
|
-
"maxLength": 140,
|
|
90
|
-
"format": "grounded-quote",
|
|
91
|
-
"examples": [
|
|
92
|
-
"Look closely. There is always more to see."
|
|
93
|
-
],
|
|
94
|
-
"description": "Exact supplied quotation; do not paraphrase or invent."
|
|
95
|
-
},
|
|
96
|
-
"attribution": {
|
|
97
|
-
"type": "string",
|
|
98
|
-
"minLength": 1,
|
|
99
|
-
"maxLength": 60,
|
|
100
|
-
"examples": [
|
|
101
|
-
"Illustrative example"
|
|
102
|
-
],
|
|
103
|
-
"description": "Grounded attribution belonging to these exact quoted words."
|
|
104
|
-
},
|
|
105
|
-
"mediaKeyword": {
|
|
106
|
-
"type": "string",
|
|
107
|
-
"format": "stock-media-keyword",
|
|
108
|
-
"minLength": 1,
|
|
109
|
-
"maxLength": 80,
|
|
110
|
-
"description": "2–8 word literal subject/action search intent, maximum 80 characters. Host resolves URLs.",
|
|
111
|
-
"examples": [
|
|
112
|
-
"Ocean waves breaking on a rocky shore"
|
|
113
|
-
]
|
|
114
|
-
},
|
|
115
|
-
"mediaUrl": {
|
|
116
|
-
"type": "string",
|
|
117
|
-
"format": "uri",
|
|
118
|
-
"description": "Host-only approved photo or video URL.",
|
|
119
|
-
"default": ""
|
|
120
|
-
},
|
|
121
|
-
"mediaType": {
|
|
122
|
-
"type": "string",
|
|
123
|
-
"enum": [
|
|
124
|
-
"photo",
|
|
125
|
-
"video"
|
|
126
|
-
],
|
|
127
|
-
"default": "video",
|
|
128
|
-
"description": "Host-resolved asset kind."
|
|
129
|
-
},
|
|
130
|
-
"mediaPoster": {
|
|
131
|
-
"type": "string",
|
|
132
|
-
"format": "uri",
|
|
133
|
-
"description": "Host-only approved poster URL for video decoding.",
|
|
134
|
-
"default": ""
|
|
135
|
-
},
|
|
136
|
-
"shotDirection": {
|
|
137
|
-
"type": "string",
|
|
138
|
-
"maxLength": 220,
|
|
139
|
-
"description": "Optional action, framing and continuity for generation. Preserve the subject; do not request rendered text."
|
|
140
|
-
},
|
|
141
|
-
"mediaSource": {
|
|
142
|
-
"type": "string",
|
|
143
|
-
"enum": [
|
|
144
|
-
"generate",
|
|
145
|
-
"stock"
|
|
146
|
-
],
|
|
147
|
-
"description": "Generate distinctive illustrative shots; use approved stock for familiar observable subjects."
|
|
148
|
-
}
|
|
149
|
-
},
|
|
150
|
-
"required": [
|
|
151
|
-
"quote",
|
|
152
|
-
"attribution"
|
|
153
|
-
],
|
|
154
|
-
"additionalProperties": false,
|
|
155
|
-
"x-vanillasky": {
|
|
156
|
-
"allowsStockMedia": true
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
-
"name": "template-context",
|
|
4
|
-
"type": "registry:lib",
|
|
5
|
-
"title": "Template context",
|
|
6
|
-
"description": "Shared brand, style, and safe-zone types for scene templates.",
|
|
7
|
-
"files": [
|
|
8
|
-
{
|
|
9
|
-
"path": "src/visual-system/template-context.ts",
|
|
10
|
-
"type": "registry:lib",
|
|
11
|
-
"target": "vanillasky/template-context.ts",
|
|
12
|
-
"content": "/** Shared render context for built-in and project-owned scene templates. */\nexport interface TemplateStyle {\n preset?: string;\n density?: string;\n motion?: string;\n defaultTextArchetype?: string;\n defaultTransition?: string;\n defaultBackgroundEffect?: string;\n}\n\nexport interface SafeZone {\n top: number;\n bottom: number;\n left: number;\n right: number;\n}\n"
|
|
13
|
-
}
|
|
14
|
-
],
|
|
15
|
-
"meta": {
|
|
16
|
-
"vanillasky": {
|
|
17
|
-
"layer": "lib",
|
|
18
|
-
"tier": "free",
|
|
19
|
-
"domain": "foundation",
|
|
20
|
-
"level": "foundation",
|
|
21
|
-
"audiences": [
|
|
22
|
-
"video-makers",
|
|
23
|
-
"custom-scenes",
|
|
24
|
-
"react-developers"
|
|
25
|
-
],
|
|
26
|
-
"dependencies": [],
|
|
27
|
-
"useWhen": "Use it when a scene or primitive needs the shared template style, brand, or safe-zone contract.",
|
|
28
|
-
"avoidWhen": "Do not use it as the public Video data contract; import that from @vanillaskyai/video."
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
}
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
-
"name": "theme",
|
|
4
|
-
"type": "registry:lib",
|
|
5
|
-
"title": "Theme",
|
|
6
|
-
"description": "Resolve brand inputs into coherent, readable scene tokens.",
|
|
7
|
-
"registryDependencies": [
|
|
8
|
-
"@vanillasky/template-context"
|
|
9
|
-
],
|
|
10
|
-
"files": [
|
|
11
|
-
{
|
|
12
|
-
"path": "src/visual-system/theme/index.ts",
|
|
13
|
-
"type": "registry:lib",
|
|
14
|
-
"target": "vanillasky/theme/index.ts",
|
|
15
|
-
"content": "/** Brand, color, type-treatment, density, and motion-style resolution. */\nexport * from \"../scene-templates/tokens\";\nexport * from \"./colors\";\n"
|
|
16
|
-
},
|
|
17
|
-
{
|
|
18
|
-
"path": "src/visual-system/theme/colors.ts",
|
|
19
|
-
"type": "registry:lib",
|
|
20
|
-
"target": "vanillasky/theme/colors.ts",
|
|
21
|
-
"content": "/** Export-safe color and contrast helpers shared by themes and backgrounds. */\n\n/** Convert a 3-, 6-, or 8-digit hex color plus opacity to rgba(). */\nexport function withOpacity(hex: string, opacity: number): string {\n const match = hex.match(/^#([0-9a-f]{3,8})$/i);\n if (!match) return hex;\n\n const h = match[1];\n let r: number;\n let g: number;\n let b: number;\n if (h.length === 3) {\n r = parseInt(h[0] + h[0], 16);\n g = parseInt(h[1] + h[1], 16);\n b = parseInt(h[2] + h[2], 16);\n } else if (h.length >= 6) {\n r = parseInt(h.slice(0, 2), 16);\n g = parseInt(h.slice(2, 4), 16);\n b = parseInt(h.slice(4, 6), 16);\n } else {\n return hex;\n }\n return `rgba(${r},${g},${b},${opacity})`;\n}\n\n/** Return whether a hex color is dark enough to prefer light foreground text. */\nexport function isColorDark(hex: string): boolean {\n const match = hex.match(/^#([0-9a-f]{3,8})$/i);\n if (!match) return true;\n\n const h = match[1];\n let r: number;\n let g: number;\n let b: number;\n if (h.length === 3) {\n r = parseInt(h[0] + h[0], 16);\n g = parseInt(h[1] + h[1], 16);\n b = parseInt(h[2] + h[2], 16);\n } else if (h.length >= 6) {\n r = parseInt(h.slice(0, 2), 16);\n g = parseInt(h.slice(2, 4), 16);\n b = parseInt(h.slice(4, 6), 16);\n } else {\n return true;\n }\n return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5;\n}\n\n/** Order two six-digit hex colors from darker to lighter. */\nexport function orderDarkToLight(a: string, b: string): [string, string] {\n const luminance = (hex: string): number => {\n const match = hex.match(/^#([0-9a-f]{6})$/i);\n if (!match) return 0;\n const h = match[1];\n return (\n 0.299 * parseInt(h.slice(0, 2), 16) +\n 0.587 * parseInt(h.slice(2, 4), 16) +\n 0.114 * parseInt(h.slice(4, 6), 16)\n );\n };\n return luminance(a) <= luminance(b) ? [a, b] : [b, a];\n}\n\nfunction relativeLuminance(hex: string): number | undefined {\n const match = hex.match(/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i);\n if (!match) return undefined;\n const value = match[1].length === 3\n ? match[1].split(\"\").map((character) => character + character).join(\"\")\n : match[1].slice(0, 6);\n const channel = (offset: number) => {\n const encoded = Number.parseInt(value.slice(offset, offset + 2), 16) / 255;\n return encoded <= 0.04045 ? encoded / 12.92 : ((encoded + 0.055) / 1.055) ** 2.4;\n };\n return 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4);\n}\n\nfunction contrastRatio(first: string, second: string): number {\n const firstLuminance = relativeLuminance(first);\n const secondLuminance = relativeLuminance(second);\n if (firstLuminance == null || secondLuminance == null) return 0;\n return (Math.max(firstLuminance, secondLuminance) + 0.05) /\n (Math.min(firstLuminance, secondLuminance) + 0.05);\n}\n\n/**\n * Resolve text for a solid surface without adding another semantic token.\n * A preferred semantic color wins when it is accessible; otherwise the\n * higher-contrast black/white treatment is selected deterministically.\n */\nexport function accessibleTextColor(background: string, preferred?: string): string {\n if (preferred && contrastRatio(background, preferred) >= 4.5) return preferred;\n return contrastRatio(background, \"#FFFFFF\") >= contrastRatio(background, \"#000000\")\n ? \"#FFFFFF\"\n : \"#000000\";\n}\n\n/** Pick a WCAG-readable light or dark text color for a background. */\nexport function autoTextColor(background: string): string {\n return accessibleTextColor(background);\n}\n"
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
"path": "src/visual-system/scene-templates/tokens.ts",
|
|
25
|
-
"type": "registry:lib",
|
|
26
|
-
"target": "vanillasky/scene-templates/tokens.ts",
|
|
27
|
-
"content": "/** Internal monochrome render tokens for cinematic scenes and shared primitives. */\nimport type { TemplateStyle } from \"../template-context\";\n\n// ─── Canonical defaults (the only place these values are defined) ──\n\nexport const TOKEN_DEFAULTS = {\n /** Primary brand colour — CTA, highlights. */\n primary: \"#00E5A0\",\n /** Deepest background surface. */\n surface: \"#0A0A14\",\n /** Elevated card / panel surface. */\n surfaceElevated: \"#14152A\",\n /** Primary text color. */\n foreground: \"#FFFFFF\",\n /** Muted text — labels, footers, supporting copy. */\n muted: \"#A7A6B0\",\n /** Primary sans font family (first name of the stack). */\n font: \"Inter\",\n /** Script accent font family for handwritten callouts. */\n scriptFont: \"Caveat\",\n} as const;\n\n/**\n * The canonical template font stack: first family of the resolved brand font, backed by\n * OS-native sans fallbacks that render identically in preview and the\n * SVG-as-image export path.\n */\nexport function fontStack(styleFont: string | undefined): string {\n return `${(styleFont || \"\").split(\",\")[0].trim() || TOKEN_DEFAULTS.font}, -apple-system, BlinkMacSystemFont, \"Helvetica Neue\", Helvetica, Arial, sans-serif`;\n}\n\n// ─── Resolved tokens ───────────────────────────────────────────────\n\n// ─── Style presets ─────────────────────────────────────────────────\n\n/**\n * Background families a preset can pick. Each resolves to a pure CSS\n * background string in `gradientBackground` — no CSS `filter`, which the\n * SVG export path cannot rasterize.\n */\nexport type BackgroundFamily = \"mesh\" | \"wash\" | \"spotlight\";\n\n/** Title placement a preset defaults to (SceneFrameVariant, minus no-title). */\nexport type PresetTitlePlacement = \"title-top\" | \"title-center\";\n\nexport interface TypeTreatment {\n /** Added to the computed fontWeight (clamped 100–900). */\n weightDelta: number;\n /** Added to the size role's letterSpacing, in em. */\n trackingDeltaEm: number;\n /** Multiplies the computed fontSize. */\n sizeScale: number;\n /** Applied as CSS text-transform when set. */\n transform?: \"uppercase\";\n /**\n * Multiplies every text-archetype entrance/exit phase duration. Set by\n * `resolveTokens` from `style.motion`; absent on the raw preset literals,\n * where it reads as 1.\n *\n * It rides on the type treatment because that object is the one channel\n * that already flows from `style` into text rendering. Pacing of\n * typographic motion is part of how the type is treated, so this isn't a\n * smuggled payload.\n */\n phaseScale?: number;\n}\n\nexport interface StylePreset {\n id: string;\n /** Agent-facing use-when — surfaced in the registry index. */\n useWhen: string;\n background: BackgroundFamily;\n titlePlacement: PresetTitlePlacement;\n type: TypeTreatment;\n}\n\n/**\n * The named looks. `bold` is the default and is a deliberate no-op: a config\n * with no `preset` resolves to it and renders byte-identically to the\n * pre-preset output, so adding presets can't restyle anyone's existing video.\n *\n * Deliberately small. Every preset multiplies the QA surface by every\n * template at both orientations — grow this only when a brief can't be\n * expressed by the ones here.\n */\nexport const STYLE_PRESETS: Record<string, StylePreset> = {\n bold: {\n id: \"bold\",\n useWhen:\n \"The default. Drifting two-color brand mesh, heavy tight headlines at the top. Launches, hype, product moments — the loudest of the three.\",\n background: \"mesh\",\n titlePlacement: \"title-top\",\n type: { weightDelta: 0, trackingDeltaEm: 0, sizeScale: 1 },\n },\n editorial: {\n id: \"editorial\",\n useWhen:\n \"Calm vertical wash, lighter and wider-tracked headlines, centered. Reviews, thoughtful updates, premium or B2B brands — when the copy should feel considered rather than shouted.\",\n background: \"wash\",\n titlePlacement: \"title-center\",\n type: { weightDelta: -200, trackingDeltaEm: 0.01, sizeScale: 1.08 },\n },\n stark: {\n id: \"stark\",\n useWhen:\n \"Single hard spotlight on near-black, uppercase and tightly tracked. Dev tools, technical claims, high-contrast statements — maximum weight on very few words.\",\n background: \"spotlight\",\n titlePlacement: \"title-top\",\n type: { weightDelta: 100, trackingDeltaEm: -0.01, sizeScale: 1, transform: \"uppercase\" },\n },\n};\n\nexport const DEFAULT_PRESET_ID = \"bold\";\nexport const PRESET_IDS = Object.keys(STYLE_PRESETS);\n\n/** Unknown/unset ids fall back to the default rather than throwing — a bad\n * preset should never be the reason a render fails. */\nexport function resolvePreset(id: string | undefined): StylePreset {\n return (id && STYLE_PRESETS[id]) || STYLE_PRESETS[DEFAULT_PRESET_ID];\n}\n\n// ─── Density & motion ──────────────────────────────────────────────\n//\n// Two dimensions orthogonal to the named preset. `preset` answers \"which\n// look\"; these answer \"how loud\". Splitting them is what lets \"make the\n// whole video more understated\" be one instruction instead of hand-tuning\n// every scene: they resolve into multipliers on levers that already reach\n// all 28 templates, so no template file knows they exist.\n//\n// Both default to `normal`, whose multipliers are all 1 — a config that sets\n// neither renders byte-identically to the pre-density output. Same invariant\n// the presets hold.\n\nexport type StyleDensity = \"airy\" | \"normal\" | \"packed\";\nexport type StyleMotion = \"calm\" | \"normal\" | \"punchy\";\n\nexport interface DensityScale {\n id: StyleDensity;\n /** Agent-facing use-when — surfaced in the registry index. */\n useWhen: string;\n /** Multiplies the headline font size (via the preset's `type.sizeScale`). */\n typeScale: number;\n /** Multiplies the frame's safe-zone insets — bigger insets, more air. */\n safeZoneScale: number;\n}\n\nexport interface MotionScale {\n id: StyleMotion;\n /** Agent-facing use-when — surfaced in the registry index. */\n useWhen: string;\n /** Multiplies every text-archetype entrance/exit phase duration. */\n phaseScale: number;\n}\n\nexport const DENSITY_SCALES: Record<StyleDensity, DensityScale> = {\n airy: {\n id: \"airy\",\n useWhen:\n \"Smaller headlines held further off the frame edges. Premium, considered, editorial — when the copy should have room to breathe.\",\n typeScale: 0.92,\n safeZoneScale: 1.3,\n },\n normal: {\n id: \"normal\",\n useWhen: \"The default. No change to type size or frame padding.\",\n typeScale: 1,\n safeZoneScale: 1,\n },\n packed: {\n id: \"packed\",\n useWhen:\n \"Bigger headlines pushed closer to the edges. Dense, urgent, information-heavy — when the frame should feel full.\",\n typeScale: 1.08,\n safeZoneScale: 0.8,\n },\n};\n\nexport const MOTION_SCALES: Record<StyleMotion, MotionScale> = {\n calm: {\n id: \"calm\",\n useWhen:\n \"Slower entrances and exits — text eases in rather than arriving. Founder stories, sober data, anything reflective.\",\n phaseScale: 1.4,\n },\n normal: {\n id: \"normal\",\n useWhen: \"The default. Archetype timings as authored.\",\n phaseScale: 1,\n },\n punchy: {\n id: \"punchy\",\n useWhen:\n \"Snappier entrances and exits — text lands fast and clears fast. Hype, launches, hot takes.\",\n phaseScale: 0.7,\n },\n};\n\nexport const DEFAULT_DENSITY_ID: StyleDensity = \"normal\";\nexport const DEFAULT_MOTION_ID: StyleMotion = \"normal\";\nexport const DENSITY_IDS = Object.keys(DENSITY_SCALES) as StyleDensity[];\nexport const MOTION_IDS = Object.keys(MOTION_SCALES) as StyleMotion[];\n\n/** Unknown/unset ids fall back to `normal`, same as `resolvePreset`. */\nexport function resolveDensity(id: string | undefined): DensityScale {\n return (id && DENSITY_SCALES[id as StyleDensity]) || DENSITY_SCALES[DEFAULT_DENSITY_ID];\n}\n\n/** Unknown/unset ids fall back to `normal`, same as `resolvePreset`. */\nexport function resolveMotion(id: string | undefined): MotionScale {\n return (id && MOTION_SCALES[id as StyleMotion]) || MOTION_SCALES[DEFAULT_MOTION_ID];\n}\n\nexport interface ResolvedTokens {\n /** Primary brand colour. */\n primary: string;\n /** Secondary brand colour. */\n secondary: string;\n /** Visual background, deliberately separate from semantic foreground colours. */\n background: { type: \"solid\"; color: string } | { type: \"gradient\"; colors: [string, string] };\n /** Deepest background surface. */\n surface: string;\n /** Elevated card / panel surface. */\n surfaceElevated: string;\n /** Primary foreground colour. */\n foreground: string;\n /** Muted text color. */\n muted: string;\n /** Full font fallback stack (see fontStack). */\n font: string;\n /** Script accent font family. */\n scriptFont: string;\n logoUrl?: string;\n name?: string;\n /**\n * Resolved style preset — frame-level look. Always set.\n *\n * `preset.type` is the composed treatment, not the raw preset literal:\n * `sizeScale` already carries the density multiplier and `phaseScale`\n * carries the motion one, which is how both dials reach text rendering\n * without a template edit.\n */\n preset: StylePreset;\n /** Resolved density dial. Always set; `normal` when unset. */\n density: DensityScale;\n /** Resolved motion dial. Always set; `normal` when unset. */\n motion: MotionScale;\n}\n\nexport function resolveTokens(\n style: TemplateStyle,\n): ResolvedTokens {\n\n // Compose the two dials into the preset's type treatment here, once, so\n // folding them in at the resolver is what makes them bite everywhere with\n // no template edits. At `normal`/`normal` both multipliers are 1 and the\n // object is value-identical to the preset literal.\n const preset = resolvePreset(style.preset);\n const density = resolveDensity(style.density);\n const motion = resolveMotion(style.motion);\n const composedPreset: StylePreset = {\n ...preset,\n type: {\n ...preset.type,\n sizeScale: preset.type.sizeScale * density.typeScale,\n phaseScale: motion.phaseScale,\n },\n };\n\n return {\n primary: \"#FFFFFF\",\n secondary: \"#FFFFFF\",\n background: { type: \"solid\", color: \"#000000\" },\n surface: \"#000000\",\n surfaceElevated: \"#171717\",\n foreground: \"#FFFFFF\",\n muted: \"#B7B7BC\",\n font: '-apple-system, BlinkMacSystemFont, \"Helvetica Neue\", Roboto, Arial, sans-serif',\n scriptFont: \"Georgia\",\n preset: composedPreset,\n density,\n motion,\n };\n}\n\n// ─── Color math the resolver depends on ────────────────────────────\n// (Lives beside the resolver because theme owns both token resolution and\n// color derivation.)\n\n/**\n * Shift a hex color's hue by a number of degrees.\n * Used by internal template treatments that need a related hue.\n */\nexport function shiftHue(hex: string, degrees: number): string {\n const r = parseInt(hex.slice(1, 3), 16) / 255;\n const g = parseInt(hex.slice(3, 5), 16) / 255;\n const b = parseInt(hex.slice(5, 7), 16) / 255;\n\n const max = Math.max(r, g, b);\n const min = Math.min(r, g, b);\n const d = max - min;\n const l = (max + min) / 2;\n let h = 0;\n let s = 0;\n\n if (d > 0) {\n s = l > 0.5 ? d / (2 - max - min) : d / (max + min);\n if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;\n else if (max === g) h = ((b - r) / d + 2) / 6;\n else h = ((r - g) / d + 4) / 6;\n }\n\n h = (h + degrees / 360 + 1) % 1;\n\n const hue2rgb = (p: number, q: number, t: number) => {\n if (t < 0) t += 1;\n if (t > 1) t -= 1;\n if (t < 1 / 6) return p + (q - p) * 6 * t;\n if (t < 1 / 2) return q;\n if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;\n return p;\n };\n\n let r2: number, g2: number, b2: number;\n if (s === 0) {\n r2 = g2 = b2 = l;\n } else {\n const q = l < 0.5 ? l * (1 + s) : l + s - l * s;\n const p = 2 * l - q;\n r2 = hue2rgb(p, q, h + 1 / 3);\n g2 = hue2rgb(p, q, h);\n b2 = hue2rgb(p, q, h - 1 / 3);\n }\n\n const toHex = (v: number) =>\n Math.round(v * 255)\n .toString(16)\n .padStart(2, \"0\");\n return `#${toHex(r2)}${toHex(g2)}${toHex(b2)}`;\n}\n\n/** Lighten a hex color by a 0-1 factor. */\n/** Scale a hex color toward black by `factor` (0 = unchanged, 1 = black). */\nexport function darken(hex: string, factor: number): string {\n if (!hex.startsWith(\"#\") || (hex.length !== 4 && hex.length !== 7)) {\n return hex;\n }\n const full = hex.length === 4\n ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`\n : hex;\n const ch = (i: number) =>\n Math.max(0, Math.round(parseInt(full.slice(i, i + 2), 16) * (1 - factor)))\n .toString(16)\n .padStart(2, \"0\");\n return `#${ch(1)}${ch(3)}${ch(5)}`;\n}\n\nexport function lighten(hex: string, factor: number): string {\n if (!hex.startsWith(\"#\") || (hex.length !== 4 && hex.length !== 7)) {\n return hex;\n }\n const full = hex.length === 4\n ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`\n : hex;\n const r = parseInt(full.slice(1, 3), 16);\n const g = parseInt(full.slice(3, 5), 16);\n const b = parseInt(full.slice(5, 7), 16);\n const lr = Math.min(255, Math.round(r + (255 - r) * factor));\n const lg = Math.min(255, Math.round(g + (255 - g) * factor));\n const lb = Math.min(255, Math.round(b + (255 - b) * factor));\n return `#${lr.toString(16).padStart(2, \"0\")}${lg.toString(16).padStart(2, \"0\")}${lb.toString(16).padStart(2, \"0\")}`;\n}\n\n/**\n * Per-glyph halo for type sitting directly on photo or video.\n *\n * A frame-wide scrim can only trade picture for contrast, and it loses that\n * trade against blown-out highlights: holding white type at 4.5:1 over a\n * near-white region needs ~0.8 alpha of black across the whole plate. A\n * two-layer shadow buys the same local separation for free — a tight 8px pass\n * for edge definition against fine texture, a wide 16px pass for the soft\n * falloff that separates the word from whatever sits behind it. Export-safe:\n * `text-shadow` survives SVG capture, `filter`/`backdrop-filter` do not.\n */\nexport const MEDIA_TEXT_SHADOW =\n \"0 2px 8px rgba(0,0,0,0.55), 0 6px 16px rgba(0,0,0,0.35)\";\n"
|
|
28
|
-
}
|
|
29
|
-
],
|
|
30
|
-
"meta": {
|
|
31
|
-
"vanillasky": {
|
|
32
|
-
"layer": "lib",
|
|
33
|
-
"tier": "free",
|
|
34
|
-
"domain": "appearance",
|
|
35
|
-
"level": "foundation",
|
|
36
|
-
"audiences": [
|
|
37
|
-
"custom-scenes",
|
|
38
|
-
"react-developers"
|
|
39
|
-
],
|
|
40
|
-
"dependencies": [
|
|
41
|
-
"template-context"
|
|
42
|
-
],
|
|
43
|
-
"useWhen": "Use resolveTokens once per scene whenever the visual result should inherit the video's brand, preset, density, or motion intensity coherently.",
|
|
44
|
-
"avoidWhen": "Do not hardcode fallback colors or font stacks beside it, and do not use it to animate values or paint a background."
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
}
|