@vanillaskyai/video 0.10.21 → 0.10.23
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 +18 -1
- package/README.md +5 -0
- package/dist/{chunk-HKUI5BTB.js → chunk-44WND2VP.js} +44 -0
- package/dist/cli.js +0 -5
- package/dist/react.js +69 -47
- package/dist/server.js +4 -25
- package/docs/architecture.md +25 -4
- package/package.json +2 -3
- package/registry/items/theme.json +1 -1
- package/starters/video-chat/package.json +1 -1
- package/styles/video-chat.css +14 -0
- package/docs/maintainers/cinematic-migration.md +0 -38
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ VanillaSky follows semantic versioning. This changelog begins with the 0.1 beta.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.10.23
|
|
8
|
+
|
|
9
|
+
- Stop shipping the maintainer-only cinematic migration note in the published
|
|
10
|
+
package. The changelog now links it on GitHub.
|
|
11
|
+
- Remove the unreferenced text-rendering and emoji modules from the source tree.
|
|
12
|
+
The bundle is unchanged; this code was already excluded from it.
|
|
13
|
+
- Correct the repository map in the architecture guide, and drop the stale
|
|
14
|
+
comments that described a text component the templates no longer use. Those
|
|
15
|
+
comments also shipped inside the installable `theme` registry item.
|
|
16
|
+
- Remove the CLI redirect for template command names that were renamed before
|
|
17
|
+
the package had users. Use `vanillasky templates <command>`.
|
|
18
|
+
|
|
19
|
+
## 0.10.22
|
|
20
|
+
|
|
21
|
+
- Offer eight diverse homepage prompts with curated footage, a balanced fresh-page shuffle, and stable ordering when returning Home.
|
|
22
|
+
- Browse four cards on desktop and two on mobile, with only the active card loading video.
|
|
23
|
+
|
|
7
24
|
## 0.10.21
|
|
8
25
|
|
|
9
26
|
- Choose illustrated, realistic, or cinematic generated-video direction within the existing chat brief, with consistent treatment across shots and caller style overrides.
|
|
@@ -170,7 +187,7 @@ Before:
|
|
|
170
187
|
|
|
171
188
|
### Adoption
|
|
172
189
|
|
|
173
|
-
Remove brand options and regenerate source-owned templates from the new catalog. Re-author or regenerate saved videos from retained source material; do not rename old IDs or change their version field blindly. See [cinematic migration](docs/maintainers/cinematic-migration.md).
|
|
190
|
+
Remove brand options and regenerate source-owned templates from the new catalog. Re-author or regenerate saved videos from retained source material; do not rename old IDs or change their version field blindly. See [cinematic migration](https://github.com/VanillaSkyAi/video/blob/main/docs/maintainers/cinematic-migration.md).
|
|
174
191
|
|
|
175
192
|
```tsx
|
|
176
193
|
<VideoChat options={{ endpoint: "/api/video-chat" }} />
|
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Give your AI a voice and a face
|
|
2
2
|
|
|
3
|
+
[](https://github.com/VanillaSkyAi/video/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@vanillaskyai/video)
|
|
5
|
+
[](package.json)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
3
8
|
**VanillaSky is the open-source voice-and-video chat layer.** Add a polished,
|
|
4
9
|
general-purpose AI conversation that speaks and starts playing visual answers
|
|
5
10
|
while they are still being composed.
|
|
@@ -8,6 +8,47 @@ import {
|
|
|
8
8
|
VIDEO_PROTOCOL_VERSION
|
|
9
9
|
} from "./chunk-7NZOMTAL.js";
|
|
10
10
|
|
|
11
|
+
// src/video-chat/welcome-cards.ts
|
|
12
|
+
var WELCOME_CARDS = [
|
|
13
|
+
{ prompt: "Why do cats stare at us?", category: "curiosity", media: { "url": "https://videos.pexels.com/video-files/6131490/6131490-sd_640_360_25fps.mp4", "type": "video", "posterUrl": "https://images.pexels.com/videos/6131490/pictures/preview-0.jpg?auto=compress&fit=crop&w=640" } },
|
|
14
|
+
{ prompt: "Tell me a joke about office life", category: "entertainment", media: { "url": "https://videos.pexels.com/video-files/7438239/7438239-sd_640_338_25fps.mp4", "type": "video", "posterUrl": "https://images.pexels.com/videos/7438239/pictures/preview-0.jpg?auto=compress&fit=crop&w=640" } },
|
|
15
|
+
{ prompt: "Where would you take me in Japan?", category: "explore", media: { "url": "https://videos.pexels.com/video-files/35972506/15252635_360_640_30fps.mp4", "type": "video", "posterUrl": "https://images.pexels.com/videos/35972506/pictures/preview-0.jpg?auto=compress&fit=crop&w=640" } },
|
|
16
|
+
{ prompt: "How do I make better coffee?", category: "practical", media: { "url": "https://videos.pexels.com/video-files/5564283/5564283-sd_640_360_24fps.mp4", "type": "video", "posterUrl": "https://images.pexels.com/videos/5564283/pictures/preview-0.jpg?auto=compress&fit=crop&w=640" } },
|
|
17
|
+
{ prompt: "What would Earth look like without humans?", category: "explore", media: { "url": "https://videos.pexels.com/video-files/28732001/12464538_640_360_30fps.mp4", "type": "video", "posterUrl": "https://images.pexels.com/videos/28732001/pictures/preview-0.jpg?auto=compress&fit=crop&w=640" } },
|
|
18
|
+
{ prompt: "Tell me a short story with a twist", category: "entertainment", media: { "url": "https://videos.pexels.com/video-files/5683159/5683159-sd_640_360_30fps.mp4", "type": "video", "posterUrl": "https://images.pexels.com/videos/5683159/pictures/preview-0.jpg?auto=compress&fit=crop&w=640" } },
|
|
19
|
+
{ prompt: "Why does music give us goosebumps?", category: "curiosity", media: { "url": "https://videos.pexels.com/video-files/6870450/6870450-sd_640_360_30fps.mp4", "type": "video", "posterUrl": "https://images.pexels.com/videos/6870450/pictures/preview-0.jpg?auto=compress&fit=crop&w=640" } },
|
|
20
|
+
{ prompt: "How can I make my room feel bigger?", category: "practical", media: { "url": "https://videos.pexels.com/video-files/37479012/15875787_360_640_30fps.mp4", "type": "video", "posterUrl": "https://images.pexels.com/videos/37479012/pictures/preview-0.jpg?auto=compress&fit=crop&w=640" } }
|
|
21
|
+
];
|
|
22
|
+
function orderWelcomeCards(cards, seed) {
|
|
23
|
+
if (cards.length !== WELCOME_CARDS.length || new Set(cards.map((card) => card.prompt)).size !== cards.length || cards.some((card) => !WELCOME_CARDS.some((entry) => entry.prompt === card.prompt))) return [...cards];
|
|
24
|
+
let state = seed >>> 0;
|
|
25
|
+
const random = () => {
|
|
26
|
+
state = Math.imul(state, 1664525) + 1013904223 >>> 0;
|
|
27
|
+
return state / 4294967296;
|
|
28
|
+
};
|
|
29
|
+
const shuffle = (values) => {
|
|
30
|
+
const copy = [...values];
|
|
31
|
+
for (let i = copy.length - 1; i > 0; i--) {
|
|
32
|
+
const j = Math.floor(random() * (i + 1));
|
|
33
|
+
[copy[i], copy[j]] = [copy[j], copy[i]];
|
|
34
|
+
}
|
|
35
|
+
return copy;
|
|
36
|
+
};
|
|
37
|
+
const groups = /* @__PURE__ */ new Map();
|
|
38
|
+
for (const card of shuffle(cards)) {
|
|
39
|
+
const category = WELCOME_CARDS.find((entry) => entry.prompt === card.prompt).category;
|
|
40
|
+
const group = groups.get(category) ?? [];
|
|
41
|
+
group.push(card);
|
|
42
|
+
groups.set(category, group);
|
|
43
|
+
}
|
|
44
|
+
const categories = shuffle([...groups.values()]);
|
|
45
|
+
return [...categories.map((group) => group[0]), ...shuffle(categories.flatMap((group) => group.slice(1)))];
|
|
46
|
+
}
|
|
47
|
+
var pageSeed;
|
|
48
|
+
function welcomeVisitSeed() {
|
|
49
|
+
return pageSeed ??= Math.floor(Math.random() * 4294967296);
|
|
50
|
+
}
|
|
51
|
+
|
|
11
52
|
// src/video-chat/recovery.ts
|
|
12
53
|
var MEDIA_RECOVERY_NOTICE = "Some visuals were replaced so your response can continue.";
|
|
13
54
|
|
|
@@ -160,6 +201,9 @@ function safeMediaUrl(value) {
|
|
|
160
201
|
}
|
|
161
202
|
|
|
162
203
|
export {
|
|
204
|
+
WELCOME_CARDS,
|
|
205
|
+
orderWelcomeCards,
|
|
206
|
+
welcomeVisitSeed,
|
|
163
207
|
MEDIA_RECOVERY_NOTICE,
|
|
164
208
|
encodeVideoSseEvent,
|
|
165
209
|
decodeVideoSse,
|
package/dist/cli.js
CHANGED
|
@@ -1994,11 +1994,6 @@ function runVanillaSkyCli(argv, environment = {}) {
|
|
|
1994
1994
|
return result.ok ? 0 : 1;
|
|
1995
1995
|
}
|
|
1996
1996
|
if (rootCommand !== "templates") {
|
|
1997
|
-
const removed = /* @__PURE__ */ new Set(["list", "describe", "add", "sync", "check", "create"]);
|
|
1998
|
-
if (rootCommand && removed.has(rootCommand)) {
|
|
1999
|
-
write(`Template commands moved. Use: vanillasky templates ${argv.join(" ")}`);
|
|
2000
|
-
return 1;
|
|
2001
|
-
}
|
|
2002
1997
|
write(help());
|
|
2003
1998
|
return rootCommand == null || rootCommand === "help" || rootCommand === "--help" || rootCommand === "-h" ? 0 : 1;
|
|
2004
1999
|
}
|
package/dist/react.js
CHANGED
|
@@ -9,9 +9,11 @@ import {
|
|
|
9
9
|
import {
|
|
10
10
|
MEDIA_RECOVERY_NOTICE,
|
|
11
11
|
decodeVideoSse,
|
|
12
|
+
orderWelcomeCards,
|
|
12
13
|
sanitizeVideoChatMedia,
|
|
14
|
+
welcomeVisitSeed,
|
|
13
15
|
withDeadline
|
|
14
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-44WND2VP.js";
|
|
15
17
|
import {
|
|
16
18
|
applyVideoEvent,
|
|
17
19
|
checksumVideo,
|
|
@@ -2311,7 +2313,7 @@ function useVideoChatSession(options = {}) {
|
|
|
2311
2313
|
if (!response.ok) throw await responseError(response);
|
|
2312
2314
|
return response.json();
|
|
2313
2315
|
}).then((value) => {
|
|
2314
|
-
if (mountedRef.current) dispatch({ type: "welcome", value });
|
|
2316
|
+
if (mountedRef.current) dispatch({ type: "welcome", value: { ...value, cards: orderWelcomeCards(value.cards, welcomeVisitSeed()) } });
|
|
2315
2317
|
}).catch(() => void 0);
|
|
2316
2318
|
return () => {
|
|
2317
2319
|
endTiming();
|
|
@@ -3008,7 +3010,7 @@ function CaptionPages({ text, getProgress }) {
|
|
|
3008
3010
|
}
|
|
3009
3011
|
|
|
3010
3012
|
// src/video-chat/video-chat.tsx
|
|
3011
|
-
import { useCallback as
|
|
3013
|
+
import { useCallback as useCallback5, useEffect as useEffect10, useId as useId2, useLayoutEffect as useLayoutEffect3, useMemo as useMemo3, useRef as useRef9, useState as useState8 } from "react";
|
|
3012
3014
|
|
|
3013
3015
|
// src/video-chat/icons.tsx
|
|
3014
3016
|
import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
@@ -3118,7 +3120,7 @@ function useFocusTrap(active, surface) {
|
|
|
3118
3120
|
}
|
|
3119
3121
|
|
|
3120
3122
|
// src/video-chat/suggestion-cards.tsx
|
|
3121
|
-
import {
|
|
3123
|
+
import { useEffect as useEffect7, useId, useRef as useRef5, useState as useState4 } from "react";
|
|
3122
3124
|
import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
3123
3125
|
function Frame({ media, poster, playing, onReady, onError, revealWhenReady = false }) {
|
|
3124
3126
|
const video = useRef5(null);
|
|
@@ -3130,6 +3132,9 @@ function Frame({ media, poster, playing, onReady, onError, revealWhenReady = fal
|
|
|
3130
3132
|
else element.pause();
|
|
3131
3133
|
}, [playing]);
|
|
3132
3134
|
if (!media) return null;
|
|
3135
|
+
if (playing === false && media.type === "video" && media.posterUrl) {
|
|
3136
|
+
return /* @__PURE__ */ jsx5("img", { className: "frame-media frame-poster", src: media.posterUrl, alt: "", onLoad: onReady, onError });
|
|
3137
|
+
}
|
|
3133
3138
|
const ready = () => {
|
|
3134
3139
|
setPlayingUrl(media.url);
|
|
3135
3140
|
onReady?.();
|
|
@@ -3161,37 +3166,38 @@ function Frame({ media, poster, playing, onReady, onError, revealWhenReady = fal
|
|
|
3161
3166
|
poster && media.posterUrl && playingUrl !== media.url && /* @__PURE__ */ jsx5("img", { className: "frame-media frame-poster", src: media.posterUrl, alt: "", onLoad: onReady })
|
|
3162
3167
|
] });
|
|
3163
3168
|
}
|
|
3164
|
-
function SuggestionCards({ suggestions, label, onAsk }) {
|
|
3169
|
+
function SuggestionCards({ suggestions, label, onAsk, browse = false }) {
|
|
3165
3170
|
const railRef = useRef5(null);
|
|
3171
|
+
const railId = useId();
|
|
3166
3172
|
const [at, setAt] = useState4(0);
|
|
3167
|
-
const [
|
|
3168
|
-
const take = useCallback3((index) => {
|
|
3169
|
-
setTaken(true);
|
|
3170
|
-
setAt(index);
|
|
3171
|
-
}, []);
|
|
3172
|
-
useEffect7(() => {
|
|
3173
|
-
if (taken || suggestions.length < 2) return;
|
|
3174
|
-
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
|
|
3175
|
-
const tour = window.setInterval(() => setAt((index) => (index + 1) % suggestions.length), 5e3);
|
|
3176
|
-
return () => window.clearInterval(tour);
|
|
3177
|
-
}, [taken, suggestions.length]);
|
|
3173
|
+
const [overflow, setOverflow] = useState4({ before: false, after: false });
|
|
3178
3174
|
useEffect7(() => {
|
|
3179
3175
|
const rail = railRef.current;
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3176
|
+
if (!browse || !rail) return;
|
|
3177
|
+
const update = () => setOverflow({ before: rail.scrollLeft > 2, after: rail.scrollLeft + rail.clientWidth < rail.scrollWidth - 2 });
|
|
3178
|
+
update();
|
|
3179
|
+
rail.addEventListener("scroll", update, { passive: true });
|
|
3180
|
+
const observer = typeof ResizeObserver === "undefined" ? void 0 : new ResizeObserver(update);
|
|
3181
|
+
observer?.observe(rail);
|
|
3182
|
+
return () => {
|
|
3183
|
+
rail.removeEventListener("scroll", update);
|
|
3184
|
+
observer?.disconnect();
|
|
3185
|
+
};
|
|
3186
|
+
}, [browse, suggestions.length]);
|
|
3187
|
+
const move = (direction) => {
|
|
3188
|
+
const rail = railRef.current;
|
|
3189
|
+
if (!rail) return;
|
|
3190
|
+
rail.scrollBy({ left: direction * rail.clientWidth, behavior: window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
|
|
3191
|
+
};
|
|
3186
3192
|
if (suggestions.length === 0) return null;
|
|
3187
|
-
|
|
3188
|
-
/* @__PURE__ */ jsx5("ul", { className: "cards", ref: railRef, "aria-label": label, children: suggestions.map((card, index) => /* @__PURE__ */ jsx5("li", { children: /* @__PURE__ */ jsxs4(
|
|
3193
|
+
const cards = /* @__PURE__ */ jsxs4(Fragment2, { children: [
|
|
3194
|
+
/* @__PURE__ */ jsx5("ul", { className: "cards", id: railId, ref: railRef, "aria-label": label, children: suggestions.map((card, index) => /* @__PURE__ */ jsx5("li", { children: /* @__PURE__ */ jsxs4(
|
|
3189
3195
|
"button",
|
|
3190
3196
|
{
|
|
3191
3197
|
type: "button",
|
|
3192
3198
|
"data-active": index === at ? "" : void 0,
|
|
3193
|
-
onFocus: () =>
|
|
3194
|
-
onPointerEnter: () =>
|
|
3199
|
+
onFocus: () => setAt(index),
|
|
3200
|
+
onPointerEnter: () => setAt(index),
|
|
3195
3201
|
onClick: () => onAsk(card),
|
|
3196
3202
|
children: [
|
|
3197
3203
|
/* @__PURE__ */ jsx5(Frame, { media: card.media, poster: true, playing: index === at }),
|
|
@@ -3207,11 +3213,23 @@ function SuggestionCards({ suggestions, label, onAsk }) {
|
|
|
3207
3213
|
"aria-pressed": index === at,
|
|
3208
3214
|
"aria-label": `Show suggestion ${index + 1}: ${card.prompt}`,
|
|
3209
3215
|
className: index === at ? "on" : void 0,
|
|
3210
|
-
onClick: () =>
|
|
3216
|
+
onClick: () => {
|
|
3217
|
+
setAt(index);
|
|
3218
|
+
const rail = railRef.current;
|
|
3219
|
+
const card2 = rail?.children[index];
|
|
3220
|
+
if (rail && card2) rail.scrollTo({ left: card2.offsetLeft, behavior: "smooth" });
|
|
3221
|
+
}
|
|
3211
3222
|
},
|
|
3212
3223
|
card.prompt
|
|
3213
3224
|
)) })
|
|
3214
3225
|
] });
|
|
3226
|
+
return browse ? /* @__PURE__ */ jsxs4("div", { className: "suggestion-rail", children: [
|
|
3227
|
+
cards,
|
|
3228
|
+
(overflow.before || overflow.after) && /* @__PURE__ */ jsxs4("div", { className: "rail-arrows", children: [
|
|
3229
|
+
/* @__PURE__ */ jsx5("button", { type: "button", "aria-label": "Previous suggestions", "aria-controls": railId, disabled: !overflow.before, onClick: () => move(-1), children: "\u2039" }),
|
|
3230
|
+
/* @__PURE__ */ jsx5("button", { type: "button", "aria-label": "Next suggestions", "aria-controls": railId, disabled: !overflow.after, onClick: () => move(1), children: "\u203A" })
|
|
3231
|
+
] })
|
|
3232
|
+
] }) : cards;
|
|
3215
3233
|
}
|
|
3216
3234
|
|
|
3217
3235
|
// src/video-chat/welcome.tsx
|
|
@@ -3226,7 +3244,7 @@ function Welcome({ data, onAsk, title }) {
|
|
|
3226
3244
|
/* @__PURE__ */ jsx6("br", {}),
|
|
3227
3245
|
/* @__PURE__ */ jsx6("em", { children: "in video, not text." })
|
|
3228
3246
|
] }) }),
|
|
3229
|
-
/* @__PURE__ */ jsx6(SuggestionCards, { suggestions: data?.cards ?? [], label: "Suggested prompts", onAsk })
|
|
3247
|
+
/* @__PURE__ */ jsx6(SuggestionCards, { browse: true, suggestions: data?.cards ?? [], label: "Suggested prompts", onAsk })
|
|
3230
3248
|
] })
|
|
3231
3249
|
] });
|
|
3232
3250
|
}
|
|
@@ -3276,7 +3294,7 @@ function OpeningChapter({ title, preparing = false }) {
|
|
|
3276
3294
|
}
|
|
3277
3295
|
|
|
3278
3296
|
// src/video-chat/use-voice-input.ts
|
|
3279
|
-
import { useCallback as
|
|
3297
|
+
import { useCallback as useCallback3, useEffect as useEffect8, useRef as useRef7, useState as useState6 } from "react";
|
|
3280
3298
|
function recognitionConstructor() {
|
|
3281
3299
|
if (typeof window === "undefined") return void 0;
|
|
3282
3300
|
const holder = window;
|
|
@@ -3358,7 +3376,7 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
|
|
|
3358
3376
|
useEffect8(() => {
|
|
3359
3377
|
setSupported(supportsVoiceInput(transcriptionAvailable));
|
|
3360
3378
|
}, [transcriptionAvailable]);
|
|
3361
|
-
const stop =
|
|
3379
|
+
const stop = useCallback3(() => {
|
|
3362
3380
|
recognitionRef.current?.abort();
|
|
3363
3381
|
recognitionRef.current = void 0;
|
|
3364
3382
|
const operation = operationRef.current;
|
|
@@ -3376,7 +3394,7 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
|
|
|
3376
3394
|
operation?.controller.abort();
|
|
3377
3395
|
operation?.finish?.();
|
|
3378
3396
|
}, []);
|
|
3379
|
-
const finish =
|
|
3397
|
+
const finish = useCallback3(() => {
|
|
3380
3398
|
const recognition = recognitionRef.current;
|
|
3381
3399
|
if (recognition) {
|
|
3382
3400
|
recognition.stop();
|
|
@@ -3390,7 +3408,7 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
|
|
|
3390
3408
|
}
|
|
3391
3409
|
stop();
|
|
3392
3410
|
}, [stop]);
|
|
3393
|
-
const record =
|
|
3411
|
+
const record = useCallback3(async () => {
|
|
3394
3412
|
if (!transcriptionAvailable || !recorderAvailable()) {
|
|
3395
3413
|
setError("Server transcription is not configured.");
|
|
3396
3414
|
return;
|
|
@@ -3428,7 +3446,7 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
|
|
|
3428
3446
|
}
|
|
3429
3447
|
}
|
|
3430
3448
|
}, [transcriptionAvailable, options.endpoint, options.headers, options.credentials, options.fetcher]);
|
|
3431
|
-
const toggle =
|
|
3449
|
+
const toggle = useCallback3(() => {
|
|
3432
3450
|
if (listening) {
|
|
3433
3451
|
finish();
|
|
3434
3452
|
return;
|
|
@@ -3480,18 +3498,18 @@ function useVoiceInput(onTranscript, transcriptionAvailable = false, options = {
|
|
|
3480
3498
|
}
|
|
3481
3499
|
|
|
3482
3500
|
// src/video-chat/use-immersive-controls.ts
|
|
3483
|
-
import { useCallback as
|
|
3501
|
+
import { useCallback as useCallback4, useEffect as useEffect9, useRef as useRef8, useState as useState7 } from "react";
|
|
3484
3502
|
function useImmersiveControls(playing, pinned, hasCaptions = false) {
|
|
3485
3503
|
const [visible, setVisible] = useState7(true);
|
|
3486
3504
|
const hovered = useRef8(false);
|
|
3487
3505
|
const focused = useRef8(false);
|
|
3488
3506
|
const previousCaptions = useRef8(false);
|
|
3489
3507
|
const timer = useRef8(null);
|
|
3490
|
-
const clearTimer =
|
|
3508
|
+
const clearTimer = useCallback4(() => {
|
|
3491
3509
|
if (timer.current !== null) clearTimeout(timer.current);
|
|
3492
3510
|
timer.current = null;
|
|
3493
3511
|
}, []);
|
|
3494
|
-
const reveal =
|
|
3512
|
+
const reveal = useCallback4(() => {
|
|
3495
3513
|
clearTimer();
|
|
3496
3514
|
setVisible(true);
|
|
3497
3515
|
if (playing && !pinned && !hovered.current && !focused.current) {
|
|
@@ -3513,21 +3531,21 @@ function useImmersiveControls(playing, pinned, hasCaptions = false) {
|
|
|
3513
3531
|
}
|
|
3514
3532
|
return clearTimer;
|
|
3515
3533
|
}, [hasCaptions, playing, pinned, reveal, clearTimer]);
|
|
3516
|
-
const onPointerEnter =
|
|
3534
|
+
const onPointerEnter = useCallback4((event) => {
|
|
3517
3535
|
if (event?.pointerType === "touch") return;
|
|
3518
3536
|
hovered.current = true;
|
|
3519
3537
|
reveal();
|
|
3520
3538
|
}, [reveal]);
|
|
3521
|
-
const onPointerLeave =
|
|
3539
|
+
const onPointerLeave = useCallback4(() => {
|
|
3522
3540
|
if (!hovered.current) return;
|
|
3523
3541
|
hovered.current = false;
|
|
3524
3542
|
reveal();
|
|
3525
3543
|
}, [reveal]);
|
|
3526
|
-
const onFocusCapture =
|
|
3544
|
+
const onFocusCapture = useCallback4((event) => {
|
|
3527
3545
|
focused.current = event.target.matches(":focus-visible");
|
|
3528
3546
|
reveal();
|
|
3529
3547
|
}, [reveal]);
|
|
3530
|
-
const onBlurCapture =
|
|
3548
|
+
const onBlurCapture = useCallback4((event) => {
|
|
3531
3549
|
if (focused.current && !event.currentTarget.contains(event.relatedTarget)) {
|
|
3532
3550
|
focused.current = false;
|
|
3533
3551
|
reveal();
|
|
@@ -3601,7 +3619,7 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
|
|
|
3601
3619
|
if (turn.id !== chat.shownTurn?.id) return;
|
|
3602
3620
|
if (previous?.id !== turn.id || previous.mode !== turn.mode) setSelectedMode(turn.mode);
|
|
3603
3621
|
}, [chat.currentTurn, chat.shownTurn?.id]);
|
|
3604
|
-
const instanceId =
|
|
3622
|
+
const instanceId = useId2();
|
|
3605
3623
|
const historyId = `${instanceId}-history`;
|
|
3606
3624
|
const settingsId = `${instanceId}-settings`;
|
|
3607
3625
|
const promptId = `${instanceId}-prompt`;
|
|
@@ -3618,13 +3636,13 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
|
|
|
3618
3636
|
});
|
|
3619
3637
|
const historySurfaces = useMemo3(() => [historyRef, historyButtonRef], []);
|
|
3620
3638
|
const settingsSurfaces = useMemo3(() => [settingsRef, settingsButtonRef], []);
|
|
3621
|
-
const closeHistory =
|
|
3622
|
-
const closeSettings =
|
|
3639
|
+
const closeHistory = useCallback5(() => setHistoryOpen(false), []);
|
|
3640
|
+
const closeSettings = useCallback5(() => setSettingsOpen(false), []);
|
|
3623
3641
|
useDismiss(historyOpen, closeHistory, historySurfaces);
|
|
3624
3642
|
useDismiss(settingsOpen, closeSettings, settingsSurfaces);
|
|
3625
3643
|
useFocusTrap(settingsOpen, settingsRef);
|
|
3626
3644
|
useFocusTrap(historyOpen, historyRef);
|
|
3627
|
-
const ask =
|
|
3645
|
+
const ask = useCallback5((value) => {
|
|
3628
3646
|
const prompt = (typeof value === "string" ? value : value.prompt).trim();
|
|
3629
3647
|
if (!prompt) return;
|
|
3630
3648
|
setDraft("");
|
|
@@ -3639,7 +3657,7 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
|
|
|
3639
3657
|
opening: value.opening
|
|
3640
3658
|
});
|
|
3641
3659
|
}, [chat, listen]);
|
|
3642
|
-
const newSession =
|
|
3660
|
+
const newSession = useCallback5(() => {
|
|
3643
3661
|
if (chat.turns.length === 0) {
|
|
3644
3662
|
setHistoryOpen(false);
|
|
3645
3663
|
setSettingsOpen(false);
|
|
@@ -3712,7 +3730,7 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
|
|
|
3712
3730
|
const line = chat.caption ?? "";
|
|
3713
3731
|
const fullTranscript = shown?.video ? [shown.opening, ...shown.video.scenes.map((scene) => scene.narration)].filter((entry) => Boolean(entry)) : chat.transcript;
|
|
3714
3732
|
const transport = status === "narrating" ? { label: "Pause", action: chat.pause, icon: /* @__PURE__ */ jsx9(Stop, {}) } : status === "paused" ? { label: "Continue", action: chat.resume, icon: /* @__PURE__ */ jsx9(Play, {}) } : status === "ended" && shown?.completed && shown.video ? { label: "Play again", action: chat.replay, icon: /* @__PURE__ */ jsx9(Replay, {}) } : void 0;
|
|
3715
|
-
const cancelInput =
|
|
3733
|
+
const cancelInput = useCallback5(() => {
|
|
3716
3734
|
listen.stop();
|
|
3717
3735
|
setDraft("");
|
|
3718
3736
|
setEditing(false);
|
|
@@ -3765,7 +3783,11 @@ function VideoChat({ options = {}, className, welcomeTitle, showRecoveryNotice =
|
|
|
3765
3783
|
onKeyDownCapture: controls.reveal,
|
|
3766
3784
|
children: [
|
|
3767
3785
|
/* @__PURE__ */ jsxs7("header", { className: "chrome", ...controlEvents, children: [
|
|
3768
|
-
/* @__PURE__ */ jsx9("div", { className: "session-brand", children: /* @__PURE__ */ jsx9("a", { className: "home-link", href: "/", "aria-label": "Home",
|
|
3786
|
+
/* @__PURE__ */ jsx9("div", { className: "session-brand", children: /* @__PURE__ */ jsx9("a", { className: "home-link", href: "/", "aria-label": "Home", onClick: (event) => {
|
|
3787
|
+
if (window.location.pathname !== "/" || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
3788
|
+
event.preventDefault();
|
|
3789
|
+
newSession();
|
|
3790
|
+
}, children: /* @__PURE__ */ jsx9(Logo, {}) }) }),
|
|
3769
3791
|
/* @__PURE__ */ jsxs7("div", { className: "group", children: [
|
|
3770
3792
|
/* @__PURE__ */ jsxs7(
|
|
3771
3793
|
"button",
|
package/dist/server.js
CHANGED
|
@@ -20,12 +20,13 @@ import {
|
|
|
20
20
|
} from "./chunk-IQMYK5DX.js";
|
|
21
21
|
import {
|
|
22
22
|
MEDIA_RECOVERY_NOTICE,
|
|
23
|
+
WELCOME_CARDS,
|
|
23
24
|
decodeVideoSse,
|
|
24
25
|
encodeVideoSseEvent,
|
|
25
26
|
sanitizeVideoChatMedia,
|
|
26
27
|
videoSseHeaders,
|
|
27
28
|
withDeadline
|
|
28
|
-
} from "./chunk-
|
|
29
|
+
} from "./chunk-44WND2VP.js";
|
|
29
30
|
import "./chunk-RXTN2CW6.js";
|
|
30
31
|
import {
|
|
31
32
|
allowedKeys,
|
|
@@ -1328,28 +1329,6 @@ var MAX_PROMPT_CHARACTERS = 8e3;
|
|
|
1328
1329
|
var MAX_CONVERSATION_TURNS = 12;
|
|
1329
1330
|
var MAX_CONVERSATION_RESPONSE_CHARACTERS = 8e3;
|
|
1330
1331
|
var VIDEO_CHAT_OPENING_EVENT_TYPE = "data.video-chat-opening";
|
|
1331
|
-
var DEFAULT_WELCOME_PROMPTS = [
|
|
1332
|
-
{
|
|
1333
|
-
prompt: "Why does the Moon always show one face?",
|
|
1334
|
-
opening: "The Moon turns, perfectly matching its orbit.",
|
|
1335
|
-
mediaQuery: "full moon night sky"
|
|
1336
|
-
},
|
|
1337
|
-
{
|
|
1338
|
-
prompt: "Tell me a tiny story about a robot growing a garden on Mars",
|
|
1339
|
-
opening: "One patient robot is about to make Mars bloom.",
|
|
1340
|
-
mediaQuery: "robot garden mars"
|
|
1341
|
-
},
|
|
1342
|
-
{
|
|
1343
|
-
prompt: "Recommend a perfect rainy afternoon in Amsterdam",
|
|
1344
|
-
opening: "Rain makes Amsterdam's best afternoons feel even warmer.",
|
|
1345
|
-
mediaQuery: "Amsterdam rain cafe"
|
|
1346
|
-
},
|
|
1347
|
-
{
|
|
1348
|
-
prompt: "Pitch a playful ad for a coffee mug that never spills",
|
|
1349
|
-
opening: "This mug makes gravity look completely optional.",
|
|
1350
|
-
mediaQuery: "coffee mug desk"
|
|
1351
|
-
}
|
|
1352
|
-
];
|
|
1353
1332
|
function jsonError2(status, code, message, headers) {
|
|
1354
1333
|
return Response.json({ error: { code, message } }, { status, headers });
|
|
1355
1334
|
}
|
|
@@ -1658,7 +1637,7 @@ function createVideoChatHandler(options) {
|
|
|
1658
1637
|
transcription: transcribe != null,
|
|
1659
1638
|
modes: searchMedia ? ["cinematic", "pexels"] : ["cinematic"]
|
|
1660
1639
|
};
|
|
1661
|
-
const welcomePrompts = (welcomeOptions?.prompts ??
|
|
1640
|
+
const welcomePrompts = (welcomeOptions?.prompts ?? WELCOME_CARDS).slice(0, 8);
|
|
1662
1641
|
const heroQuery = welcomeOptions?.heroQuery;
|
|
1663
1642
|
let welcomeResponse;
|
|
1664
1643
|
let requestSequence = 0;
|
|
@@ -1860,7 +1839,7 @@ function createVideoChatHandler(options) {
|
|
|
1860
1839
|
};
|
|
1861
1840
|
const [hero, ...cards] = await Promise.all([
|
|
1862
1841
|
heroQuery === void 0 ? DEFAULT_WELCOME_HERO : resolve(heroQuery),
|
|
1863
|
-
...welcomePrompts.map((entry) => resolve(entry.mediaQuery))
|
|
1842
|
+
...welcomePrompts.map((entry, index) => welcomeOptions?.prompts === void 0 ? WELCOME_CARDS[index]?.media ?? null : resolve("mediaQuery" in entry ? entry.mediaQuery : void 0))
|
|
1864
1843
|
]);
|
|
1865
1844
|
return {
|
|
1866
1845
|
cacheable: !failed,
|
package/docs/architecture.md
CHANGED
|
@@ -19,6 +19,26 @@ own your product policy. It supplies the complete default chat, video-planning
|
|
|
19
19
|
prompts, trusted visual vocabulary, conversation and narration lifecycle,
|
|
20
20
|
validation, streaming, and player.
|
|
21
21
|
|
|
22
|
+
## Start here
|
|
23
|
+
|
|
24
|
+
Six files, in this order, are enough to hold the whole system in your head.
|
|
25
|
+
About 2,600 lines total, and two of them are most of it.
|
|
26
|
+
|
|
27
|
+
1. `src/protocol/types.ts` — the `Video` shape. Everything else exists to
|
|
28
|
+
produce, validate, transport, or play this one object.
|
|
29
|
+
2. `src/server/prompts/system-prompt.ts` — what the model is actually asked
|
|
30
|
+
for. The product's behavior is mostly here, not in the code around it.
|
|
31
|
+
3. `src/server/create-video-chat-handler.ts` — the one endpoint. Where an
|
|
32
|
+
application's providers and policy attach.
|
|
33
|
+
4. `src/protocol/events.ts` — the wire contract between server and browser.
|
|
34
|
+
5. `src/video-chat/use-video-chat.ts` — the client lifecycle, as a reducer.
|
|
35
|
+
Read `reducer` first and the hook second.
|
|
36
|
+
6. `src/visual-system/scene-templates/quote.tsx` — one complete scene, small
|
|
37
|
+
enough to read in a sitting. Every other template has this shape.
|
|
38
|
+
|
|
39
|
+
To watch it run instead, `npm run dev:chat` renders the real `VideoChat` from
|
|
40
|
+
source against fixtures, with no provider credentials and no spend.
|
|
41
|
+
|
|
22
42
|
## Repository map
|
|
23
43
|
|
|
24
44
|
| Location | Purpose |
|
|
@@ -32,19 +52,20 @@ validation, streaming, and player.
|
|
|
32
52
|
| `src/video-chat/` | Default `VideoChat` interface and headless conversation/session engine |
|
|
33
53
|
| `src/visual-system/catalog/` | Template metadata, schemas, loading, and planner catalog |
|
|
34
54
|
| `src/visual-system/scene-templates/` | Complete scenes the model may select |
|
|
35
|
-
| `src/visual-system/primitives/` | Reusable visual components used inside scenes |
|
|
36
55
|
| `src/visual-system/backgrounds/` | Standalone background renderers |
|
|
37
56
|
| `src/visual-system/motion/` | Animation functions and timing behavior |
|
|
38
57
|
| `src/visual-system/theme/` | Color and design tokens |
|
|
58
|
+
| `src/visual-system/typography/` | Text fitting, formatting, and kinetic type lifecycles |
|
|
39
59
|
| `src/cli/` | `vanillasky init`, `doctor`, and `providers add`, plus `vanillasky templates create`, `add`, `sync`, `check`, `list`, and `describe` |
|
|
40
60
|
| `registry/items/` | Generated distributable copies installed into customer projects |
|
|
41
61
|
| `src/index.ts`, `src/server.ts`, `src/react.ts`, `src/templates.ts`, `src/template-catalog.ts`, `src/test.ts`, `styles/video-chat.css` | The six small code entry points and one scoped stylesheet |
|
|
42
62
|
|
|
43
63
|
The source of truth for built-in visuals is `src/visual-system`. The JSON files
|
|
44
64
|
in `registry/items` are distribution artifacts, kept flat so the CLI can address
|
|
45
|
-
every installable item by a stable name. Their `meta.vanillasky.layer`
|
|
46
|
-
`
|
|
47
|
-
support code. Run `npm run registry:sync` after changing
|
|
65
|
+
every installable item by a stable name. Their `meta.vanillasky.layer` field is
|
|
66
|
+
`template` for a complete scene the model may select, or `lib` for shared
|
|
67
|
+
support code a template imports. Run `npm run registry:sync` after changing
|
|
68
|
+
canonical visual source.
|
|
48
69
|
|
|
49
70
|
Customer applications do not edit those internal locations. Their source of
|
|
50
71
|
truth is one file per visual under `vanillasky/templates/`; `vanillasky templates sync`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vanillaskyai/video",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.23",
|
|
4
4
|
"description": "Open-source voice-and-video chat SDK for AI applications.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"video-chat",
|
|
@@ -56,8 +56,7 @@
|
|
|
56
56
|
"docs/reference/protocol.md",
|
|
57
57
|
"CHANGELOG.md",
|
|
58
58
|
"docs/*.md",
|
|
59
|
-
"docs/reference"
|
|
60
|
-
"docs/maintainers/cinematic-migration.md"
|
|
59
|
+
"docs/reference"
|
|
61
60
|
],
|
|
62
61
|
"bin": {
|
|
63
62
|
"vanillasky": "bin/vanillasky.js"
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"path": "src/visual-system/scene-templates/tokens.ts",
|
|
25
25
|
"type": "registry:lib",
|
|
26
26
|
"target": "vanillasky/scene-templates/tokens.ts",
|
|
27
|
-
"content": "/** Internal monochrome render tokens for cinematic scenes and shared primitives. */\nimport type { TemplateStyle } from \"../template-context\";\n\n// ─── Canonical defaults (the only place these values are defined) ──\n\nexport const TOKEN_DEFAULTS = {\n /** Primary brand colour — CTA, highlights. */\n primary: \"#00E5A0\",\n /** Deepest background surface. */\n surface: \"#0A0A14\",\n /** Elevated card / panel surface. */\n surfaceElevated: \"#14152A\",\n /** Primary text color. */\n foreground: \"#FFFFFF\",\n /** Muted text — labels, footers, supporting copy. */\n muted: \"#A7A6B0\",\n /** Primary sans font family (first name of the stack). */\n font: \"Inter\",\n /** Script accent font family for handwritten callouts. */\n scriptFont: \"Caveat\",\n} as const;\n\n/**\n * The canonical template font stack: first family of the resolved brand font, backed by\n * OS-native sans fallbacks that render identically in preview and the\n * SVG-as-image export path.\n */\nexport function fontStack(styleFont: string | undefined): string {\n return `${(styleFont || \"\").split(\",\")[0].trim() || TOKEN_DEFAULTS.font}, -apple-system, BlinkMacSystemFont, \"Helvetica Neue\", Helvetica, Arial, sans-serif`;\n}\n\n// ─── Resolved tokens ───────────────────────────────────────────────\n\n// ─── Style presets ─────────────────────────────────────────────────\n\n/**\n * Background families a preset can pick. Each resolves to a pure CSS\n * background string in `gradientBackground` — no CSS `filter`, which the\n * SVG export path cannot rasterize.\n */\nexport type BackgroundFamily = \"mesh\" | \"wash\" | \"spotlight\";\n\n/** Title placement a preset defaults to (SceneFrameVariant, minus no-title). */\nexport type PresetTitlePlacement = \"title-top\" | \"title-center\";\n\nexport interface TypeTreatment {\n /** Added to the computed fontWeight (clamped 100–900). */\n weightDelta: number;\n /** Added to the size role's letterSpacing, in em. */\n trackingDeltaEm: number;\n /** Multiplies the computed fontSize. */\n sizeScale: number;\n /** Applied as CSS text-transform when set. */\n transform?: \"uppercase\";\n /**\n * Multiplies every text-archetype entrance/exit phase duration. Set by\n * `resolveTokens` from `style.motion`; absent on the raw preset literals,\n * where it reads as 1.\n *\n * It rides on the type treatment because that object is the one channel\n * that already flows from `style` into every `<TemplateText>` — 17 call\n * sites pass `resolveTokens(style).preset.type` and nothing else\n * style-derived. Pacing of typographic motion is part of how the type is\n * treated, so this isn't a smuggled payload.\n */\n phaseScale?: number;\n}\n\nexport interface StylePreset {\n id: string;\n /** Agent-facing use-when — surfaced in the registry index. */\n useWhen: string;\n background: BackgroundFamily;\n titlePlacement: PresetTitlePlacement;\n type: TypeTreatment;\n}\n\n/**\n * The named looks. `bold` is the default and is a deliberate no-op: a config\n * with no `preset` resolves to it and renders byte-identically to the\n * pre-preset output, so adding presets can't restyle anyone's existing video.\n *\n * Deliberately small. Every preset multiplies the QA surface by every\n * template at both orientations — grow this only when a brief can't be\n * expressed by the ones here.\n */\nexport const STYLE_PRESETS: Record<string, StylePreset> = {\n bold: {\n id: \"bold\",\n useWhen:\n \"The default. Drifting two-color brand mesh, heavy tight headlines at the top. Launches, hype, product moments — the loudest of the three.\",\n background: \"mesh\",\n titlePlacement: \"title-top\",\n type: { weightDelta: 0, trackingDeltaEm: 0, sizeScale: 1 },\n },\n editorial: {\n id: \"editorial\",\n useWhen:\n \"Calm vertical wash, lighter and wider-tracked headlines, centered. Reviews, thoughtful updates, premium or B2B brands — when the copy should feel considered rather than shouted.\",\n background: \"wash\",\n titlePlacement: \"title-center\",\n type: { weightDelta: -200, trackingDeltaEm: 0.01, sizeScale: 1.08 },\n },\n stark: {\n id: \"stark\",\n useWhen:\n \"Single hard spotlight on near-black, uppercase and tightly tracked. Dev tools, technical claims, high-contrast statements — maximum weight on very few words.\",\n background: \"spotlight\",\n titlePlacement: \"title-top\",\n type: { weightDelta: 100, trackingDeltaEm: -0.01, sizeScale: 1, transform: \"uppercase\" },\n },\n};\n\nexport const DEFAULT_PRESET_ID = \"bold\";\nexport const PRESET_IDS = Object.keys(STYLE_PRESETS);\n\n/** Unknown/unset ids fall back to the default rather than throwing — a bad\n * preset should never be the reason a render fails. */\nexport function resolvePreset(id: string | undefined): StylePreset {\n return (id && STYLE_PRESETS[id]) || STYLE_PRESETS[DEFAULT_PRESET_ID];\n}\n\n// ─── Density & motion ──────────────────────────────────────────────\n//\n// Two dimensions orthogonal to the named preset. `preset` answers \"which\n// look\"; these answer \"how loud\". Splitting them is what lets \"make the\n// whole video more understated\" be one instruction instead of hand-tuning\n// every scene: they resolve into multipliers on levers that already reach\n// all 28 templates, so no template file knows they exist.\n//\n// Both default to `normal`, whose multipliers are all 1 — a config that sets\n// neither renders byte-identically to the pre-density output. Same invariant\n// the presets hold.\n\nexport type StyleDensity = \"airy\" | \"normal\" | \"packed\";\nexport type StyleMotion = \"calm\" | \"normal\" | \"punchy\";\n\nexport interface DensityScale {\n id: StyleDensity;\n /** Agent-facing use-when — surfaced in the registry index. */\n useWhen: string;\n /** Multiplies the headline font size (via the preset's `type.sizeScale`). */\n typeScale: number;\n /** Multiplies the frame's safe-zone insets — bigger insets, more air. */\n safeZoneScale: number;\n}\n\nexport interface MotionScale {\n id: StyleMotion;\n /** Agent-facing use-when — surfaced in the registry index. */\n useWhen: string;\n /** Multiplies every text-archetype entrance/exit phase duration. */\n phaseScale: number;\n}\n\nexport const DENSITY_SCALES: Record<StyleDensity, DensityScale> = {\n airy: {\n id: \"airy\",\n useWhen:\n \"Smaller headlines held further off the frame edges. Premium, considered, editorial — when the copy should have room to breathe.\",\n typeScale: 0.92,\n safeZoneScale: 1.3,\n },\n normal: {\n id: \"normal\",\n useWhen: \"The default. No change to type size or frame padding.\",\n typeScale: 1,\n safeZoneScale: 1,\n },\n packed: {\n id: \"packed\",\n useWhen:\n \"Bigger headlines pushed closer to the edges. Dense, urgent, information-heavy — when the frame should feel full.\",\n typeScale: 1.08,\n safeZoneScale: 0.8,\n },\n};\n\nexport const MOTION_SCALES: Record<StyleMotion, MotionScale> = {\n calm: {\n id: \"calm\",\n useWhen:\n \"Slower entrances and exits — text eases in rather than arriving. Founder stories, sober data, anything reflective.\",\n phaseScale: 1.4,\n },\n normal: {\n id: \"normal\",\n useWhen: \"The default. Archetype timings as authored.\",\n phaseScale: 1,\n },\n punchy: {\n id: \"punchy\",\n useWhen:\n \"Snappier entrances and exits — text lands fast and clears fast. Hype, launches, hot takes.\",\n phaseScale: 0.7,\n },\n};\n\nexport const DEFAULT_DENSITY_ID: StyleDensity = \"normal\";\nexport const DEFAULT_MOTION_ID: StyleMotion = \"normal\";\nexport const DENSITY_IDS = Object.keys(DENSITY_SCALES) as StyleDensity[];\nexport const MOTION_IDS = Object.keys(MOTION_SCALES) as StyleMotion[];\n\n/** Unknown/unset ids fall back to `normal`, same as `resolvePreset`. */\nexport function resolveDensity(id: string | undefined): DensityScale {\n return (id && DENSITY_SCALES[id as StyleDensity]) || DENSITY_SCALES[DEFAULT_DENSITY_ID];\n}\n\n/** Unknown/unset ids fall back to `normal`, same as `resolvePreset`. */\nexport function resolveMotion(id: string | undefined): MotionScale {\n return (id && MOTION_SCALES[id as StyleMotion]) || MOTION_SCALES[DEFAULT_MOTION_ID];\n}\n\nexport interface ResolvedTokens {\n /** Primary brand colour. */\n primary: string;\n /** Secondary brand colour. */\n secondary: string;\n /** Visual background, deliberately separate from semantic foreground colours. */\n background: { type: \"solid\"; color: string } | { type: \"gradient\"; colors: [string, string] };\n /** Deepest background surface. */\n surface: string;\n /** Elevated card / panel surface. */\n surfaceElevated: string;\n /** Primary foreground colour. */\n foreground: string;\n /** Muted text color. */\n muted: string;\n /** Full font fallback stack (see fontStack). */\n font: string;\n /** Script accent font family. */\n scriptFont: string;\n logoUrl?: string;\n name?: string;\n /**\n * Resolved style preset — frame-level look. Always set.\n *\n * `preset.type` is the composed treatment, not the raw preset literal:\n * `sizeScale` already carries the density multiplier and `phaseScale`\n * carries the motion one. Templates pass this straight to `<TemplateText>`,\n * which is how both dials reach every template without a template edit.\n */\n preset: StylePreset;\n /** Resolved density dial. Always set; `normal` when unset. */\n density: DensityScale;\n /** Resolved motion dial. Always set; `normal` when unset. */\n motion: MotionScale;\n}\n\nexport function resolveTokens(\n style: TemplateStyle,\n): ResolvedTokens {\n\n // Compose the two dials into the preset's type treatment here, once. Every\n // template already passes `preset.type` to <TemplateText>, so folding them\n // in at the resolver is what makes them bite everywhere with no template\n // edits. At `normal`/`normal` both multipliers are 1 and the object is\n // value-identical to the preset literal.\n const preset = resolvePreset(style.preset);\n const density = resolveDensity(style.density);\n const motion = resolveMotion(style.motion);\n const composedPreset: StylePreset = {\n ...preset,\n type: {\n ...preset.type,\n sizeScale: preset.type.sizeScale * density.typeScale,\n phaseScale: motion.phaseScale,\n },\n };\n\n return {\n primary: \"#FFFFFF\",\n secondary: \"#FFFFFF\",\n background: { type: \"solid\", color: \"#000000\" },\n surface: \"#000000\",\n surfaceElevated: \"#171717\",\n foreground: \"#FFFFFF\",\n muted: \"#B7B7BC\",\n font: '-apple-system, BlinkMacSystemFont, \"Helvetica Neue\", Roboto, Arial, sans-serif',\n scriptFont: \"Georgia\",\n preset: composedPreset,\n density,\n motion,\n };\n}\n\n// ─── Color math the resolver depends on ────────────────────────────\n// (Lives beside the resolver because theme owns both token resolution and\n// color derivation.)\n\n/**\n * Shift a hex color's hue by a number of degrees.\n * Used by internal template treatments that need a related hue.\n */\nexport function shiftHue(hex: string, degrees: number): string {\n const r = parseInt(hex.slice(1, 3), 16) / 255;\n const g = parseInt(hex.slice(3, 5), 16) / 255;\n const b = parseInt(hex.slice(5, 7), 16) / 255;\n\n const max = Math.max(r, g, b);\n const min = Math.min(r, g, b);\n const d = max - min;\n const l = (max + min) / 2;\n let h = 0;\n let s = 0;\n\n if (d > 0) {\n s = l > 0.5 ? d / (2 - max - min) : d / (max + min);\n if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;\n else if (max === g) h = ((b - r) / d + 2) / 6;\n else h = ((r - g) / d + 4) / 6;\n }\n\n h = (h + degrees / 360 + 1) % 1;\n\n const hue2rgb = (p: number, q: number, t: number) => {\n if (t < 0) t += 1;\n if (t > 1) t -= 1;\n if (t < 1 / 6) return p + (q - p) * 6 * t;\n if (t < 1 / 2) return q;\n if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;\n return p;\n };\n\n let r2: number, g2: number, b2: number;\n if (s === 0) {\n r2 = g2 = b2 = l;\n } else {\n const q = l < 0.5 ? l * (1 + s) : l + s - l * s;\n const p = 2 * l - q;\n r2 = hue2rgb(p, q, h + 1 / 3);\n g2 = hue2rgb(p, q, h);\n b2 = hue2rgb(p, q, h - 1 / 3);\n }\n\n const toHex = (v: number) =>\n Math.round(v * 255)\n .toString(16)\n .padStart(2, \"0\");\n return `#${toHex(r2)}${toHex(g2)}${toHex(b2)}`;\n}\n\n/** Lighten a hex color by a 0-1 factor. */\n/** Scale a hex color toward black by `factor` (0 = unchanged, 1 = black). */\nexport function darken(hex: string, factor: number): string {\n if (!hex.startsWith(\"#\") || (hex.length !== 4 && hex.length !== 7)) {\n return hex;\n }\n const full = hex.length === 4\n ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`\n : hex;\n const ch = (i: number) =>\n Math.max(0, Math.round(parseInt(full.slice(i, i + 2), 16) * (1 - factor)))\n .toString(16)\n .padStart(2, \"0\");\n return `#${ch(1)}${ch(3)}${ch(5)}`;\n}\n\nexport function lighten(hex: string, factor: number): string {\n if (!hex.startsWith(\"#\") || (hex.length !== 4 && hex.length !== 7)) {\n return hex;\n }\n const full = hex.length === 4\n ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`\n : hex;\n const r = parseInt(full.slice(1, 3), 16);\n const g = parseInt(full.slice(3, 5), 16);\n const b = parseInt(full.slice(5, 7), 16);\n const lr = Math.min(255, Math.round(r + (255 - r) * factor));\n const lg = Math.min(255, Math.round(g + (255 - g) * factor));\n const lb = Math.min(255, Math.round(b + (255 - b) * factor));\n return `#${lr.toString(16).padStart(2, \"0\")}${lg.toString(16).padStart(2, \"0\")}${lb.toString(16).padStart(2, \"0\")}`;\n}\n\n/**\n * Per-glyph halo for type sitting directly on photo or video.\n *\n * A frame-wide scrim can only trade picture for contrast, and it loses that\n * trade against blown-out highlights: holding white type at 4.5:1 over a\n * near-white region needs ~0.8 alpha of black across the whole plate. A\n * two-layer shadow buys the same local separation for free — a tight 8px pass\n * for edge definition against fine texture, a wide 16px pass for the soft\n * falloff that separates the word from whatever sits behind it. Export-safe:\n * `text-shadow` survives SVG capture, `filter`/`backdrop-filter` do not.\n */\nexport const MEDIA_TEXT_SHADOW =\n \"0 2px 8px rgba(0,0,0,0.55), 0 6px 16px rgba(0,0,0,0.35)\";\n"
|
|
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
28
|
}
|
|
29
29
|
],
|
|
30
30
|
"meta": {
|
package/styles/video-chat.css
CHANGED
|
@@ -1127,3 +1127,17 @@
|
|
|
1127
1127
|
.vanillasky-video-chat .transcript-toggle { display: inline-flex; align-items: center; gap: 8px; min-height: 40px; padding: 8px 12px; border: 0; border-radius: 10px; background: var(--vs-media-glass); color: var(--vs-media-muted); font: inherit; font-size: 13px; cursor: pointer; pointer-events: auto; }
|
|
1128
1128
|
.vanillasky-video-chat .transcript-toggle svg { width: 16px; height: 16px; }
|
|
1129
1129
|
.vanillasky-video-chat .transcript-toggle:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
|
|
1130
|
+
|
|
1131
|
+
/* Welcome shows a deliberate four-card window; the rest stays in the rail. */
|
|
1132
|
+
.vanillasky-video-chat .suggestion-rail { position: relative; flex-shrink: 0; width: calc(4 * clamp(160px, 18vw, 260px) + 66px); max-width: calc(100% + 24px); margin: auto -12px 0; }
|
|
1133
|
+
.vanillasky-video-chat .suggestion-rail .cards { width: 100%; max-width: 100%; margin: 0; }
|
|
1134
|
+
.vanillasky-video-chat .rail-arrows { position: absolute; inset: 50% -6px auto; display: flex; justify-content: space-between; transform: translateY(-50%); pointer-events: none; }
|
|
1135
|
+
.vanillasky-video-chat .rail-arrows button { width: 36px; height: 36px; border: 1px solid rgb(255 255 255 / 30%); border-radius: 50%; background: #202438; color: white; font: inherit; font-size: 25px; line-height: 1; cursor: pointer; pointer-events: auto; }
|
|
1136
|
+
.vanillasky-video-chat .rail-arrows button:disabled { visibility: hidden; pointer-events: none; }
|
|
1137
|
+
.vanillasky-video-chat .rail-arrows button:focus-visible { outline: 2px solid white; outline-offset: 3px; }
|
|
1138
|
+
@media (max-width: 700px) {
|
|
1139
|
+
.vanillasky-video-chat .suggestion-rail { width: calc(100% + 40px); max-width: calc(100% + 40px); margin-inline: -20px; }
|
|
1140
|
+
.vanillasky-video-chat .rail-arrows { display: none; }
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
@media (hover: none) { .vanillasky-video-chat .rail-arrows { display: none; } }
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
# Cinematic contract migration
|
|
2
|
-
|
|
3
|
-
The eight-template replacement is an approved breaking pre-1.0 change. Persisted Video schema is now `0.2`; event protocol is `0.6`.
|
|
4
|
-
|
|
5
|
-
## Breaking changes
|
|
6
|
-
|
|
7
|
-
- Removed `VideoInput.brand`, `VideoStyle.brand`, `VideoBrand`, `VideoBrandInput`, `VideoBackground`, and `resolveVideoBrand`. Graphics use black, white/neutral text and system typography. Naturally colored media is unaffected.
|
|
8
|
-
- Removed all 28 old built-in IDs. Eight new IDs describe different schemas: `cinemaMedia`, `chapterTitle`, `focusCards`, `editorialTimeline`, `mobileMessage`, `comparison`, `quote`, `keyFigure`.
|
|
9
|
-
- `parseVideo` rejects the old persisted `0.1` fixture with `unsupported_video_version`. The parser also rejects `style.brand` if a caller merely stamps the new version onto an old object.
|
|
10
|
-
- Scene source intent uses `mediaKeyword` (up to 80 characters) and optional `mediaSource`. The host owns `mediaUrl`, `mediaPoster` and resolved `mediaType`. No gradient fallback mode. Full-bleed requires a resolvable intent or asset; Reach out can remain on black when media is unavailable.
|
|
11
|
-
|
|
12
|
-
## Adoption
|
|
13
|
-
|
|
14
|
-
Regenerate videos from the retained source and narration using the new catalog. Do not automatically relabel old IDs: before/after emojis, count-ups and factual quote/stat schemas have different meanings. Retain an older published SDK in a separate legacy playback boundary if historical exports must continue to render; do not pass those payloads into the new parser.
|
|
15
|
-
|
|
16
|
-
Before (old persisted format):
|
|
17
|
-
|
|
18
|
-
```json
|
|
19
|
-
{"schemaVersion":"0.1","scenes":[{"id":"one","templateId":"media","variables":{"texts":"A new perspective","mediaType":"gradient"},"timing":{"fixedDuration":4}}],"style":{"brand":{"font":"Inter"}}}
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
After re-authoring that title as a chapter:
|
|
23
|
-
|
|
24
|
-
```json
|
|
25
|
-
{"schemaVersion":"0.2","scenes":[{"id":"one","templateId":"chapterTitle","variables":{"title":"A new perspective"},"timing":{"fixedDuration":4}}],"style":{}}
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
Evidence: persistence tests preserve the untouched old release fixture and assert rejection, accept current-style round trips, reject hidden/non-JSON input as before, and pin new checksums. Template tests cover both orientations, deterministic seeking, exact evidence text and single-decoder backdrop ownership. Final packed consumer and mobile playback checks remain release gates.
|
|
29
|
-
|
|
30
|
-
## Render fonts
|
|
31
|
-
|
|
32
|
-
The cinematic templates use `-apple-system, BlinkMacSystemFont, "Helvetica Neue", Roboto, Arial, sans-serif` at regular and medium weights. Apple devices keep native system typography. The existing `@vanillaskyai/video/video-chat.css` entry registers packaged Roboto v51 WOFF2 subsets as the fallback for environments without those fonts. Standalone player and source-owned template integrations should also import that stylesheet. No font is fetched from Google at runtime; browsers fetch local packaged subsets only when Roboto is selected for the rendered glyphs. The font assets include the SIL Open Font License and a source/hash manifest.
|
|
33
|
-
|
|
34
|
-
Live native fonts and a Linux renderer’s Roboto have slightly different metrics. For repeatable exports, keep the browser, installed fonts, viewport and package version fixed, and await `document.fonts.ready` after mounting the final scene before capturing frames. This is a host export responsibility; the SDK does not add a separate export API or force downloaded fonts onto Apple devices.
|
|
35
|
-
|
|
36
|
-
## Media-led follow-up
|
|
37
|
-
|
|
38
|
-
The owner requested removing `focusCards`. Regenerate persisted videos using that ID; migrate parallel explanations to narration over footage rather than another bullet layout. The remaining seven templates retain their IDs. Comparison, quote, key figure and timeline accept optional standard media variables and remain readable on black when assets are absent. No provider callback signatures or host limits change.
|