@vanillaskyai/video 0.9.0 → 0.10.1
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 +19 -0
- package/dist/{builtin-server-HW6HGXCZ.js → builtin-server-W4PLJUZ5.js} +2 -2
- package/dist/check-runtime.js +2 -2
- package/dist/{chunk-UDIODEDK.js → chunk-224QNWRA.js} +5 -1
- package/dist/{chunk-MXEYPK7M.js → chunk-4ZJLPHBV.js} +194 -62
- package/dist/{chunk-ZDJNFA52.js → chunk-5BSJLT6H.js} +6 -6
- package/dist/{chunk-G7IZBBWF.js → chunk-5C6HZNHY.js} +12 -14
- package/dist/{chunk-3ZTEFJIR.js → chunk-6KZGF63O.js} +47 -12
- package/dist/{chunk-LH4BPALA.js → chunk-IIN5M5HW.js} +194 -61
- package/dist/{chunk-MQILEEXC.js → chunk-IR44XKBI.js} +1 -1
- package/dist/{chunk-OR52UMSF.js → chunk-JKVOBTRO.js} +3 -2
- package/dist/chunk-KHLO5OHT.js +35 -0
- package/dist/{chunk-PAJ44TFW.js → chunk-LVM5Q2DL.js} +8 -6
- package/dist/chunk-VQH3JTQC.js +16 -0
- package/dist/{cinema-media-YQHSIFDA.js → cinema-media-APDJS7SC.js} +3 -2
- package/dist/cli.js +4 -4
- package/dist/{comparison-RZMMHZR2.js → comparison-ZVE2DE7U.js} +14 -4
- package/dist/{compose-video-TBFTONUC.js → compose-video-BH5K6XWT.js} +1 -1
- package/dist/{editorial-timeline-OZX7OS6G.js → editorial-timeline-YIXXVJUV.js} +10 -3
- package/dist/key-figure-I2C37FWV.js +30 -0
- package/dist/{mobile-message-UZUPIKHD.js → mobile-message-JSGBU6EX.js} +3 -2
- package/dist/{preload-media-CFTZIMZL.js → preload-media-LJXWKTWG.js} +1 -1
- package/dist/quote-XH54G3FS.js +33 -0
- package/dist/react.js +58 -33
- package/dist/server.js +106 -27
- package/dist/{system-prompt-SYRT5DWM.js → system-prompt-AG26KJAA.js} +1 -1
- package/dist/template-catalog.d.ts +154 -19
- package/dist/template-catalog.js +1 -1
- package/dist/test.js +2 -2
- package/docs/customization.md +1 -1
- package/docs/maintainers/cinematic-migration.md +4 -0
- package/docs/media-and-audio.md +3 -3
- package/docs/provider-integration.md +1 -1
- package/package.json +1 -1
- package/registry/items/cinemaMedia.json +1 -1
- package/registry/items/comparison.json +71 -4
- package/registry/items/editorialTimeline.json +71 -4
- package/registry/items/keyFigure.json +69 -4
- package/registry/items/mobileMessage.json +1 -1
- package/registry/items/quote.json +71 -4
- package/starters/video-chat/package.json +1 -1
- package/dist/focus-cards-TJ72BT7U.js +0 -20
- package/dist/key-figure-GYENXKH3.js +0 -20
- package/dist/quote-EW5HQA6H.js +0 -23
- package/registry/items/focusCards.json +0 -90
|
@@ -66,25 +66,6 @@ declare const BUILTIN_TEMPLATE_SCHEMAS: {
|
|
|
66
66
|
readonly required: readonly ["title"];
|
|
67
67
|
readonly additionalProperties: false;
|
|
68
68
|
};
|
|
69
|
-
readonly focusCards: {
|
|
70
|
-
readonly type: "object";
|
|
71
|
-
readonly properties: {
|
|
72
|
-
readonly items: {
|
|
73
|
-
readonly type: "array";
|
|
74
|
-
readonly minItems: 2;
|
|
75
|
-
readonly maxItems: 4;
|
|
76
|
-
readonly items: {
|
|
77
|
-
readonly type: "string";
|
|
78
|
-
readonly minLength: 1;
|
|
79
|
-
readonly maxLength: 55;
|
|
80
|
-
};
|
|
81
|
-
readonly default: readonly ["Listen closely", "Notice the pattern", "Make room for change"];
|
|
82
|
-
readonly description: "2–4 parallel phrases, about 2–6 words each; no heading or numbering.";
|
|
83
|
-
};
|
|
84
|
-
};
|
|
85
|
-
readonly required: readonly ["items"];
|
|
86
|
-
readonly additionalProperties: false;
|
|
87
|
-
};
|
|
88
69
|
readonly editorialTimeline: {
|
|
89
70
|
readonly type: "object";
|
|
90
71
|
readonly properties: {
|
|
@@ -113,9 +94,48 @@ declare const BUILTIN_TEMPLATE_SCHEMAS: {
|
|
|
113
94
|
}];
|
|
114
95
|
readonly description: "3–5 ordered events or actions, each with one short label; no dates.";
|
|
115
96
|
};
|
|
97
|
+
readonly mediaKeyword: {
|
|
98
|
+
readonly type: "string";
|
|
99
|
+
readonly format: "stock-media-keyword";
|
|
100
|
+
readonly minLength: 1;
|
|
101
|
+
readonly maxLength: 80;
|
|
102
|
+
readonly description: "2–8 word literal subject/action search intent, maximum 80 characters. Host resolves URLs.";
|
|
103
|
+
readonly examples: readonly ["Ocean waves breaking on a rocky shore"];
|
|
104
|
+
};
|
|
105
|
+
readonly mediaUrl: {
|
|
106
|
+
readonly type: "string";
|
|
107
|
+
readonly format: "uri";
|
|
108
|
+
readonly description: "Host-only approved photo or video URL.";
|
|
109
|
+
readonly default: "";
|
|
110
|
+
};
|
|
111
|
+
readonly mediaType: {
|
|
112
|
+
readonly type: "string";
|
|
113
|
+
readonly enum: readonly ["photo", "video"];
|
|
114
|
+
readonly default: "video";
|
|
115
|
+
readonly description: "Host-resolved asset kind.";
|
|
116
|
+
};
|
|
117
|
+
readonly mediaPoster: {
|
|
118
|
+
readonly type: "string";
|
|
119
|
+
readonly format: "uri";
|
|
120
|
+
readonly description: "Host-only approved poster URL for video decoding.";
|
|
121
|
+
readonly default: "";
|
|
122
|
+
};
|
|
123
|
+
readonly shotDirection: {
|
|
124
|
+
readonly type: "string";
|
|
125
|
+
readonly maxLength: 220;
|
|
126
|
+
readonly description: "Optional action, framing and continuity for generation. Preserve the subject; do not request rendered text.";
|
|
127
|
+
};
|
|
128
|
+
readonly mediaSource: {
|
|
129
|
+
readonly type: "string";
|
|
130
|
+
readonly enum: readonly ["generate", "stock"];
|
|
131
|
+
readonly description: "Generate distinctive illustrative shots; use approved stock for familiar observable subjects.";
|
|
132
|
+
};
|
|
116
133
|
};
|
|
117
134
|
readonly required: readonly ["events"];
|
|
118
135
|
readonly additionalProperties: false;
|
|
136
|
+
readonly "x-vanillasky": {
|
|
137
|
+
readonly allowsStockMedia: true;
|
|
138
|
+
};
|
|
119
139
|
};
|
|
120
140
|
readonly mobileMessage: {
|
|
121
141
|
readonly type: "object";
|
|
@@ -202,9 +222,48 @@ declare const BUILTIN_TEMPLATE_SCHEMAS: {
|
|
|
202
222
|
readonly default: "More room to think";
|
|
203
223
|
readonly description: "Second real alternative, parallel to the first.";
|
|
204
224
|
};
|
|
225
|
+
readonly mediaKeyword: {
|
|
226
|
+
readonly type: "string";
|
|
227
|
+
readonly format: "stock-media-keyword";
|
|
228
|
+
readonly minLength: 1;
|
|
229
|
+
readonly maxLength: 80;
|
|
230
|
+
readonly description: "2–8 word literal subject/action search intent, maximum 80 characters. Host resolves URLs.";
|
|
231
|
+
readonly examples: readonly ["Ocean waves breaking on a rocky shore"];
|
|
232
|
+
};
|
|
233
|
+
readonly mediaUrl: {
|
|
234
|
+
readonly type: "string";
|
|
235
|
+
readonly format: "uri";
|
|
236
|
+
readonly description: "Host-only approved photo or video URL.";
|
|
237
|
+
readonly default: "";
|
|
238
|
+
};
|
|
239
|
+
readonly mediaType: {
|
|
240
|
+
readonly type: "string";
|
|
241
|
+
readonly enum: readonly ["photo", "video"];
|
|
242
|
+
readonly default: "video";
|
|
243
|
+
readonly description: "Host-resolved asset kind.";
|
|
244
|
+
};
|
|
245
|
+
readonly mediaPoster: {
|
|
246
|
+
readonly type: "string";
|
|
247
|
+
readonly format: "uri";
|
|
248
|
+
readonly description: "Host-only approved poster URL for video decoding.";
|
|
249
|
+
readonly default: "";
|
|
250
|
+
};
|
|
251
|
+
readonly shotDirection: {
|
|
252
|
+
readonly type: "string";
|
|
253
|
+
readonly maxLength: 220;
|
|
254
|
+
readonly description: "Optional action, framing and continuity for generation. Preserve the subject; do not request rendered text.";
|
|
255
|
+
};
|
|
256
|
+
readonly mediaSource: {
|
|
257
|
+
readonly type: "string";
|
|
258
|
+
readonly enum: readonly ["generate", "stock"];
|
|
259
|
+
readonly description: "Generate distinctive illustrative shots; use approved stock for familiar observable subjects.";
|
|
260
|
+
};
|
|
205
261
|
};
|
|
206
262
|
readonly required: readonly ["leftText", "rightText"];
|
|
207
263
|
readonly additionalProperties: false;
|
|
264
|
+
readonly "x-vanillasky": {
|
|
265
|
+
readonly allowsStockMedia: true;
|
|
266
|
+
};
|
|
208
267
|
};
|
|
209
268
|
readonly quote: {
|
|
210
269
|
readonly type: "object";
|
|
@@ -224,9 +283,48 @@ declare const BUILTIN_TEMPLATE_SCHEMAS: {
|
|
|
224
283
|
readonly examples: readonly ["Illustrative example"];
|
|
225
284
|
readonly description: "Grounded attribution belonging to these exact quoted words.";
|
|
226
285
|
};
|
|
286
|
+
readonly mediaKeyword: {
|
|
287
|
+
readonly type: "string";
|
|
288
|
+
readonly format: "stock-media-keyword";
|
|
289
|
+
readonly minLength: 1;
|
|
290
|
+
readonly maxLength: 80;
|
|
291
|
+
readonly description: "2–8 word literal subject/action search intent, maximum 80 characters. Host resolves URLs.";
|
|
292
|
+
readonly examples: readonly ["Ocean waves breaking on a rocky shore"];
|
|
293
|
+
};
|
|
294
|
+
readonly mediaUrl: {
|
|
295
|
+
readonly type: "string";
|
|
296
|
+
readonly format: "uri";
|
|
297
|
+
readonly description: "Host-only approved photo or video URL.";
|
|
298
|
+
readonly default: "";
|
|
299
|
+
};
|
|
300
|
+
readonly mediaType: {
|
|
301
|
+
readonly type: "string";
|
|
302
|
+
readonly enum: readonly ["photo", "video"];
|
|
303
|
+
readonly default: "video";
|
|
304
|
+
readonly description: "Host-resolved asset kind.";
|
|
305
|
+
};
|
|
306
|
+
readonly mediaPoster: {
|
|
307
|
+
readonly type: "string";
|
|
308
|
+
readonly format: "uri";
|
|
309
|
+
readonly description: "Host-only approved poster URL for video decoding.";
|
|
310
|
+
readonly default: "";
|
|
311
|
+
};
|
|
312
|
+
readonly shotDirection: {
|
|
313
|
+
readonly type: "string";
|
|
314
|
+
readonly maxLength: 220;
|
|
315
|
+
readonly description: "Optional action, framing and continuity for generation. Preserve the subject; do not request rendered text.";
|
|
316
|
+
};
|
|
317
|
+
readonly mediaSource: {
|
|
318
|
+
readonly type: "string";
|
|
319
|
+
readonly enum: readonly ["generate", "stock"];
|
|
320
|
+
readonly description: "Generate distinctive illustrative shots; use approved stock for familiar observable subjects.";
|
|
321
|
+
};
|
|
227
322
|
};
|
|
228
323
|
readonly required: readonly ["quote", "attribution"];
|
|
229
324
|
readonly additionalProperties: false;
|
|
325
|
+
readonly "x-vanillasky": {
|
|
326
|
+
readonly allowsStockMedia: true;
|
|
327
|
+
};
|
|
230
328
|
};
|
|
231
329
|
readonly keyFigure: {
|
|
232
330
|
readonly type: "object";
|
|
@@ -246,11 +344,48 @@ declare const BUILTIN_TEMPLATE_SCHEMAS: {
|
|
|
246
344
|
readonly examples: readonly ["Illustrative example"];
|
|
247
345
|
readonly description: "Exactly one short label identifying the quantity and its context.";
|
|
248
346
|
};
|
|
347
|
+
readonly mediaKeyword: {
|
|
348
|
+
readonly type: "string";
|
|
349
|
+
readonly format: "stock-media-keyword";
|
|
350
|
+
readonly minLength: 1;
|
|
351
|
+
readonly maxLength: 80;
|
|
352
|
+
readonly description: "2–8 word literal subject/action search intent, maximum 80 characters. Host resolves URLs.";
|
|
353
|
+
readonly examples: readonly ["Ocean waves breaking on a rocky shore"];
|
|
354
|
+
};
|
|
355
|
+
readonly mediaUrl: {
|
|
356
|
+
readonly type: "string";
|
|
357
|
+
readonly format: "uri";
|
|
358
|
+
readonly description: "Host-only approved photo or video URL.";
|
|
359
|
+
readonly default: "";
|
|
360
|
+
};
|
|
361
|
+
readonly mediaType: {
|
|
362
|
+
readonly type: "string";
|
|
363
|
+
readonly enum: readonly ["photo", "video"];
|
|
364
|
+
readonly default: "video";
|
|
365
|
+
readonly description: "Host-resolved asset kind.";
|
|
366
|
+
};
|
|
367
|
+
readonly mediaPoster: {
|
|
368
|
+
readonly type: "string";
|
|
369
|
+
readonly format: "uri";
|
|
370
|
+
readonly description: "Host-only approved poster URL for video decoding.";
|
|
371
|
+
readonly default: "";
|
|
372
|
+
};
|
|
373
|
+
readonly shotDirection: {
|
|
374
|
+
readonly type: "string";
|
|
375
|
+
readonly maxLength: 220;
|
|
376
|
+
readonly description: "Optional action, framing and continuity for generation. Preserve the subject; do not request rendered text.";
|
|
377
|
+
};
|
|
378
|
+
readonly mediaSource: {
|
|
379
|
+
readonly type: "string";
|
|
380
|
+
readonly enum: readonly ["generate", "stock"];
|
|
381
|
+
readonly description: "Generate distinctive illustrative shots; use approved stock for familiar observable subjects.";
|
|
382
|
+
};
|
|
249
383
|
};
|
|
250
384
|
readonly required: readonly ["value", "label"];
|
|
251
385
|
readonly additionalProperties: false;
|
|
252
386
|
readonly "x-vanillasky": {
|
|
253
387
|
readonly requiresStat: true;
|
|
388
|
+
readonly allowsStockMedia: true;
|
|
254
389
|
};
|
|
255
390
|
};
|
|
256
391
|
};
|
package/dist/template-catalog.js
CHANGED
package/dist/test.js
CHANGED
|
@@ -233,9 +233,9 @@ async function* simulateVideoStream(parts, options = {}) {
|
|
|
233
233
|
if (timeoutMs != null && (!Number.isFinite(timeoutMs) || timeoutMs < 0)) {
|
|
234
234
|
throw new Error("Simulation timeoutMs must be a non-negative finite number");
|
|
235
235
|
}
|
|
236
|
-
const { createVideo } = await import("./compose-video-
|
|
236
|
+
const { createVideo } = await import("./compose-video-BH5K6XWT.js");
|
|
237
237
|
const { createTextDeltaVideoPlanner } = await import("./text-stream-LD364KBI.js");
|
|
238
|
-
const { BUILTIN_SERVER_TEMPLATE_KIT } = await import("./builtin-server-
|
|
238
|
+
const { BUILTIN_SERVER_TEMPLATE_KIT } = await import("./builtin-server-W4PLJUZ5.js");
|
|
239
239
|
const { createTemplateSceneValidator } = await import("./validate-4OUD2CLX.js");
|
|
240
240
|
const controller = new AbortController();
|
|
241
241
|
const forwardAbort = () => controller.abort(options.signal?.reason);
|
package/docs/customization.md
CHANGED
|
@@ -52,7 +52,7 @@ explain with their own composition and motion. There is no brand-kit option.
|
|
|
52
52
|
The host can provide a shared `generatedLook` description for media preparation.
|
|
53
53
|
Custom source-owned templates can define their own visual language in code.
|
|
54
54
|
Do not rely on old global brand, text-effect, or gradient controls to restyle
|
|
55
|
-
the
|
|
55
|
+
the seven cinematic templates.
|
|
56
56
|
|
|
57
57
|
## Opening
|
|
58
58
|
|
|
@@ -32,3 +32,7 @@ Evidence: persistence tests preserve the untouched old release fixture and asser
|
|
|
32
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
33
|
|
|
34
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.
|
package/docs/media-and-audio.md
CHANGED
|
@@ -8,14 +8,14 @@ all credentials out of React and the browser bundle.
|
|
|
8
8
|
|
|
9
9
|
## One cinematic mode
|
|
10
10
|
|
|
11
|
-
The director combines relevant footage and
|
|
11
|
+
The director combines relevant footage and seven editorial templates. Graphics
|
|
12
12
|
use black and white; footage carries atmosphere and concrete action. Each media
|
|
13
13
|
scene declares `mediaSource: "stock"` or `"generate"`. Provider availability and
|
|
14
14
|
host budgets constrain those choices. There is no all-stock or all-AI switch.
|
|
15
15
|
|
|
16
|
-
The canonical templates are `cinemaMedia`, `chapterTitle`,
|
|
16
|
+
The canonical templates are `cinemaMedia`, `chapterTitle`,
|
|
17
17
|
`editorialTimeline`, `mobileMessage`, `comparison`, `quote`, and `keyFigure`.
|
|
18
|
-
|
|
18
|
+
All except `chapterTitle` accept optional media backgrounds. Editorial overlays use soft text shadows and a dark contrast scrim over relevant media, and fall back to black. Use at most one explanatory overlay per typical thirty-second answer. Full-bleed
|
|
19
19
|
footage has no headline; narration and subtitles carry the explanation.
|
|
20
20
|
|
|
21
21
|
## Reviewed stock
|
|
@@ -180,7 +180,7 @@ is intentionally substantial. It is stable for the same SDK version, template
|
|
|
180
180
|
kit, media policy, and base prompt. Record input-token usage, keep the selected
|
|
181
181
|
kit no broader than the product needs, and enable provider-side prompt caching
|
|
182
182
|
where the chosen provider/model supports it. VanillaSky does not assume one
|
|
183
|
-
provider's cache controls in its provider-neutral adapter. The cinematic catalog contains
|
|
183
|
+
provider's cache controls in its provider-neutral adapter. The cinematic catalog contains seven templates; use provider-reported token
|
|
184
184
|
usage as the authoritative measurement rather than a character estimate.
|
|
185
185
|
|
|
186
186
|
Provider finish reasons `error` and `tool-calls` are terminal failures.
|
package/package.json
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"path": "src/visual-system/scene-templates/scene-background.tsx",
|
|
30
30
|
"type": "registry:component",
|
|
31
31
|
"target": "vanillasky/scene-templates/scene-background.tsx",
|
|
32
|
-
"content": "/**\n * Shared photo/video backdrop for built-in and customer-owned templates.\n * Media is host-resolved; this component never searches or generates assets.\n * The base is fixed black. Photos and videos cover it when available, and\n * optional scrims appear only once the media can paint. The cinematic built-ins\n * request no scrim; custom templates can choose a treatment and text anchor.\n * The retained internal \"gradient\" media sentinel selects the black base and\n * ignores mediaUrl. It is not a built-in authoring mode or brand-color control.\n * Video playback can be owned by the player's persistent external backdrop.\n */\n\nimport React, { useEffect, useState } from \"react\";\nimport {\n hasSceneMedia,\n resolveMediaType,\n type ResolvedMediaType,\n} from \"./media-source\";\nimport type { TemplateStyle } from \"../template-context\";\nimport { BrandGradientOverlay } from \"../backgrounds\";\nimport { getBackgroundTransform } from \"../backgrounds\";\nimport { useExternalVideoBackdrop } from \"./external-video-backdrop\";\nimport { resolveMediaPosition, type MediaPosition } from \"./media-position\";\nimport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\n\nexport { hasSceneMedia, resolveMediaType };\nexport { resolveMediaPosition } from \"./media-position\";\nexport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\nexport type { ResolvedMediaType };\n\nexport type { MediaPosition };\nexport type MediaTreatment = \"none\" | \"subtle\" | \"cinematic\" | \"text-safe\";\n\nexport function resolveMediaTreatment(value: string): MediaTreatment {\n return value === \"none\" || value === \"subtle\" || value === \"text-safe\" ? value : \"cinematic\";\n}\n\nexport interface MediaTreatmentLayer {\n id: \"vignette\" | \"center-scrim\" | \"bottom-scrim\";\n background: string;\n style?: React.CSSProperties;\n}\n\n/**\n * Where the template puts its type. The scrim is shaped to the copy, not to\n * the frame: darkening picture the type never touches costs contrast in the\n * photo and buys no legibility. \"full\" is the conservative default for\n * templates that have not declared an anchor.\n */\nexport type MediaTextAnchor = \"center\" | \"bottom\" | \"full\";\n\n/**\n * Smoothstep-sampled alpha stops between `start`% and `end`% of the gradient\n * box, held at full strength before `start` and after `end`.\n *\n * A two-stop `rgba(0,0,0,a) → transparent` scrim ramps alpha linearly, so it\n * ends with a constant slope. Lateral inhibition in the eye amplifies that\n * slope discontinuity into a visible band — the grey bar cutting across the\n * frame that makes an overlay read as an overlay. Smoothstep flattens the\n * curve at both ends, so the scrim holds where the type sits and then leaves\n * without an edge: the same peak coverage over the copy, noticeably less of\n * the picture spent getting there.\n */\nconst SCRIM_STOP_COUNT = 7;\n\nfunction smoothstep(t: number): number {\n return t * t * (3 - 2 * t);\n}\n\nfunction easedStops(\n peakAlpha: number,\n start: number,\n end: number,\n direction: \"fade-out\" | \"fade-in\",\n): string {\n const alphaAt = (t: number): string => {\n const eased = direction === \"fade-out\" ? 1 - smoothstep(t) : smoothstep(t);\n return `rgba(0,0,0,${Number((peakAlpha * eased).toFixed(3))})`;\n };\n const stops: string[] = [];\n if (start > 0) stops.push(`${alphaAt(0)} 0%`);\n for (let i = 0; i < SCRIM_STOP_COUNT; i += 1) {\n const t = i / (SCRIM_STOP_COUNT - 1);\n const position = Number((start + (end - start) * t).toFixed(2));\n stops.push(`${alphaAt(t)} ${position}%`);\n }\n if (end < 100) stops.push(`${alphaAt(1)} 100%`);\n return stops.join(\", \");\n}\n\n/**\n * Export-safe contrast recipes. Overlays only: SVG capture cannot rely on CSS\n * filters, so a blur-behind-text plate is off the table.\n *\n * The scrims deliberately stop short of solving legibility on their own. A\n * uniform darkening strong enough to carry white type over a blown-out sky\n * needs roughly 0.8 alpha — at that point the photo is a texture, not a\n * picture. The cheaper half of the job belongs to the type: a per-glyph halo\n * (MEDIA_TEXT_SHADOW) buys local contrast exactly where it is needed and\n * costs the image nothing. Scrim for the plate, halo for the glyph.\n */\nexport function getMediaTreatmentLayers(\n value: string,\n anchor: MediaTextAnchor = \"full\",\n): MediaTreatmentLayer[] {\n const treatment = resolveMediaTreatment(value);\n // Nothing over the picture at all. Every other treatment exists to carry\n // type across a photograph; where the scene has no type - a generated clip\n // that is the whole point of the beat - even a vignette is something the\n // viewer did not ask to look through.\n if (treatment === \"none\") return [];\n const vignette: MediaTreatmentLayer = {\n id: \"vignette\",\n background:\n treatment === \"subtle\"\n ? `radial-gradient(ellipse at center, ${easedStops(0.28, 45, 100, \"fade-in\")})`\n : `radial-gradient(ellipse at center, ${easedStops(0.72, 32, 100, \"fade-in\")})`,\n };\n if (treatment === \"subtle\") return [vignette];\n\n const textSafe = treatment === \"text-safe\";\n const layers: MediaTreatmentLayer[] = [vignette];\n\n if (anchor !== \"bottom\") {\n layers.push({\n id: \"center-scrim\",\n background: textSafe\n ? `radial-gradient(ellipse 92% 58% at 50% 50%, ${easedStops(0.46, 34, 90, \"fade-out\")})`\n : `radial-gradient(ellipse 88% 52% at 50% 50%, ${easedStops(0.26, 30, 88, \"fade-out\")})`,\n });\n }\n\n if (anchor !== \"center\") {\n layers.push({\n id: \"bottom-scrim\",\n background: `linear-gradient(to top, ${easedStops(textSafe ? 0.64 : 0.5, 8, 100, \"fade-out\")})`,\n style: { top: \"55%\" },\n });\n }\n\n return layers;\n}\n\n/**\n * Whether the backdrop is actually painting, which is what decides if a scrim\n * is earned. \"pending\" is a browser-only state: static and export renders\n * never run effects and never wait on a network, so they start (and stay)\n * ready and their output is unchanged.\n */\ntype MediaPaintState = \"pending\" | \"ready\" | \"failed\";\n\nfunction initialMediaPaint(\n wantsMedia: boolean,\n resolved: ResolvedMediaType,\n mediaUrl: string,\n mediaPoster: string | undefined,\n): MediaPaintState {\n if (typeof window === \"undefined\") return \"ready\";\n if (!wantsMedia) return \"ready\";\n // A poster paints the video's frame immediately, so the scene is already\n // showing footage even though the stream is still decoding.\n if (resolved === \"video\") return mediaPoster ? \"ready\" : \"pending\";\n if (typeof Image === \"undefined\") return \"ready\";\n // Preloaded or browser-cached media decodes synchronously. Reporting it\n // ready on the first render keeps the common mid-playback case free of a\n // black-then-photo flicker.\n const cached = new Image();\n cached.src = mediaUrl;\n return cached.complete && cached.naturalWidth > 0 ? \"ready\" : \"pending\";\n}\n\nexport function getMediaBackgroundProps(variables: Record<string, unknown>) {\n return {\n mediaUrl: String(variables.mediaUrl || \"\"),\n mediaType: String(variables.mediaType || \"auto\"),\n mediaPoster: String(variables.mediaPoster || \"\"),\n mediaPosition: String(variables.mediaPosition || \"center\"),\n mediaTreatment: String(variables.mediaTreatment || \"cinematic\"),\n };\n}\n\nexport interface SceneBackgroundProps {\n style: TemplateStyle;\n progress: number;\n sceneDuration?: number;\n width: number;\n height: number;\n mediaUrl?: string;\n mediaType?: string;\n /** Still image URL shown while the <video> backdrop decodes its first\n * frame. Without it the element renders transparent during the\n * decode window and the black base shows through. */\n mediaPoster?: string;\n /** Cover-crop focal anchor. Keeps the important edge/subject visible. */\n mediaPosition?: string;\n /** Overlay recipe: subtle, cinematic, or stronger text-safe contrast. */\n mediaTreatment?: string;\n /** Where this template's copy sits, so the scrim is shaped to the type\n * instead of to the frame. Defaults to \"full\" (scrim both the middle and\n * the lower third) for templates that have not declared an anchor. */\n textAnchor?: MediaTextAnchor;\n /** Background motion effect (drift / pulse / Ken Burns). Applied to the photo/video. */\n backgroundEffect?: string;\n /** Retained seed input for customer-owned backdrop compositions. */\n seed?: number | string;\n /** Pause video when preview is paused. Defaults to true (export path). */\n isPlaying?: boolean;\n beatIntensity?: number;\n}\n\nexport const SceneBackground: React.FC<SceneBackgroundProps> = ({\n style,\n progress,\n sceneDuration,\n width: _width, // accepted for symmetry; not currently used in render\n height: _height,\n mediaUrl = \"\",\n mediaType = \"auto\",\n mediaPoster,\n mediaPosition = \"center\",\n mediaTreatment = \"cinematic\",\n textAnchor = \"full\",\n backgroundEffect,\n seed,\n isPlaying = true,\n beatIntensity = 0,\n}) => {\n void _width;\n void _height;\n const resolved = resolveMediaType(mediaType, mediaUrl);\n const wantsMedia = resolved !== \"gradient\" && !!mediaUrl;\n const externalVideoBackdrop = useExternalVideoBackdrop();\n const hasExternalVideoBackdrop = externalVideoBackdrop !== false && resolved === \"video\";\n const externalVideoFailed = externalVideoBackdrop === \"fallback\" && resolved === \"video\";\n const externalVideoReady = externalVideoBackdrop === \"ready\" && resolved === \"video\";\n\n // Apply picture and scrim together. A loading or failed asset keeps the\n // fixed black base instead of painting contrast treatment over empty media.\n const [mediaPaint, setMediaPaint] = useState<MediaPaintState>(() =>\n initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster),\n );\n useEffect(() => {\n setMediaPaint(initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster));\n // Video reports its own paint through onLoadedData / onError below.\n if (!wantsMedia || resolved !== \"photo\") return;\n if (typeof Image === \"undefined\") return;\n let cancelled = false;\n const probe = new Image();\n probe.onload = () => {\n if (!cancelled) setMediaPaint(\"ready\");\n };\n probe.onerror = () => {\n if (!cancelled) setMediaPaint(\"failed\");\n };\n probe.src = mediaUrl;\n if (probe.complete) setMediaPaint(probe.naturalWidth > 0 ? \"ready\" : \"failed\");\n return () => {\n cancelled = true;\n probe.onload = null;\n probe.onerror = null;\n };\n }, [mediaUrl, mediaPoster, resolved, wantsMedia]);\n\n // The element stays mounted while pending — that is what loads it. Only a\n // confirmed failure takes it back out.\n const showMedia = wantsMedia && mediaPaint !== \"failed\";\n const showTreatment = wantsMedia && mediaPaint === \"ready\";\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const resolvedTreatment = resolveMediaTreatment(mediaTreatment);\n const treatmentLayers = getMediaTreatmentLayers(resolvedTreatment, textAnchor);\n\n const gradSeed =\n typeof seed === \"number\"\n ? seed\n : typeof seed === \"string\"\n ? seed.split(\"\").reduce((acc, c) => acc + c.charCodeAt(0), 0)\n : 0;\n\n const bgTransform = getBackgroundTransform(\n backgroundEffect,\n progress,\n beatIntensity,\n );\n\n return (\n <>\n {(!hasExternalVideoBackdrop || externalVideoFailed) && (\n <BrandGradientOverlay\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n seed={gradSeed}\n />\n )}\n\n {showMedia && !hasExternalVideoBackdrop &&\n (resolved === \"video\" ? (\n <SceneVideoBackdrop\n mediaUrl={mediaUrl}\n mediaPoster={mediaPoster}\n mediaPosition={mediaPosition}\n backgroundEffect={backgroundEffect}\n progress={progress}\n beatIntensity={beatIntensity}\n isPlaying={isPlaying}\n onReady={() => setMediaPaint(\"ready\")}\n onError={() => setMediaPaint(\"failed\")}\n />\n ) : (\n <div\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n backgroundImage: `url(${mediaUrl})`,\n backgroundSize: \"cover\",\n backgroundPosition: resolvedPosition,\n }}\n />\n ))}\n\n {(hasExternalVideoBackdrop ? externalVideoReady : showTreatment) && !externalVideoFailed &&\n treatmentLayers.map((layer) => (\n <div\n key={layer.id}\n data-media-treatment={resolvedTreatment}\n data-media-overlay={layer.id}\n style={{\n position: \"absolute\",\n inset: 0,\n background: layer.background,\n pointerEvents: \"none\",\n ...layer.style,\n }}\n />\n ))}\n </>\n );\n};\n"
|
|
32
|
+
"content": "/**\n * Shared photo/video backdrop for built-in and customer-owned templates.\n * Media is host-resolved; this component never searches or generates assets.\n * The base is fixed black. Photos and videos cover it when available, and\n * optional scrims appear only once the media can paint. The cinematic built-ins\n * request no scrim; custom templates can choose a treatment and text anchor.\n * The retained internal \"gradient\" media sentinel selects the black base and\n * ignores mediaUrl. It is not a built-in authoring mode or brand-color control.\n * Video playback can be owned by the player's persistent external backdrop.\n */\n\nimport React, { useEffect, useState } from \"react\";\nimport {\n hasSceneMedia,\n resolveMediaType,\n type ResolvedMediaType,\n} from \"./media-source\";\nimport type { TemplateStyle } from \"../template-context\";\nimport { BrandGradientOverlay } from \"../backgrounds\";\nimport { getBackgroundTransform } from \"../backgrounds\";\nimport { useExternalVideoBackdrop } from \"./external-video-backdrop\";\nimport { resolveMediaPosition, type MediaPosition } from \"./media-position\";\nimport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\n\nexport { hasSceneMedia, resolveMediaType };\nexport { resolveMediaPosition } from \"./media-position\";\nexport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\nexport type { ResolvedMediaType };\n\nexport type { MediaPosition };\nexport type MediaTreatment = \"none\" | \"subtle\" | \"cinematic\" | \"text-safe\";\n\nexport function resolveMediaTreatment(value: string): MediaTreatment {\n return value === \"none\" || value === \"subtle\" || value === \"text-safe\" ? value : \"cinematic\";\n}\n\nexport interface MediaTreatmentLayer {\n id: \"vignette\" | \"center-scrim\" | \"bottom-scrim\";\n background: string;\n style?: React.CSSProperties;\n}\n\n/**\n * Where the template puts its type. The scrim is shaped to the copy, not to\n * the frame: darkening picture the type never touches costs contrast in the\n * photo and buys no legibility. \"full\" is the conservative default for\n * templates that have not declared an anchor.\n */\nexport type MediaTextAnchor = \"center\" | \"bottom\" | \"full\";\n\n/**\n * Smoothstep-sampled alpha stops between `start`% and `end`% of the gradient\n * box, held at full strength before `start` and after `end`.\n *\n * A two-stop `rgba(0,0,0,a) → transparent` scrim ramps alpha linearly, so it\n * ends with a constant slope. Lateral inhibition in the eye amplifies that\n * slope discontinuity into a visible band — the grey bar cutting across the\n * frame that makes an overlay read as an overlay. Smoothstep flattens the\n * curve at both ends, so the scrim holds where the type sits and then leaves\n * without an edge: the same peak coverage over the copy, noticeably less of\n * the picture spent getting there.\n */\nconst SCRIM_STOP_COUNT = 7;\n\nfunction smoothstep(t: number): number {\n return t * t * (3 - 2 * t);\n}\n\nfunction easedStops(\n peakAlpha: number,\n start: number,\n end: number,\n direction: \"fade-out\" | \"fade-in\",\n): string {\n const alphaAt = (t: number): string => {\n const eased = direction === \"fade-out\" ? 1 - smoothstep(t) : smoothstep(t);\n return `rgba(0,0,0,${Number((peakAlpha * eased).toFixed(3))})`;\n };\n const stops: string[] = [];\n if (start > 0) stops.push(`${alphaAt(0)} 0%`);\n for (let i = 0; i < SCRIM_STOP_COUNT; i += 1) {\n const t = i / (SCRIM_STOP_COUNT - 1);\n const position = Number((start + (end - start) * t).toFixed(2));\n stops.push(`${alphaAt(t)} ${position}%`);\n }\n if (end < 100) stops.push(`${alphaAt(1)} 100%`);\n return stops.join(\", \");\n}\n\n/**\n * Export-safe contrast recipes. Overlays only: SVG capture cannot rely on CSS\n * filters, so a blur-behind-text plate is off the table.\n *\n * The scrims deliberately stop short of solving legibility on their own. A\n * uniform darkening strong enough to carry white type over a blown-out sky\n * needs roughly 0.8 alpha — at that point the photo is a texture, not a\n * picture. The cheaper half of the job belongs to the type: a per-glyph halo\n * (MEDIA_TEXT_SHADOW) buys local contrast exactly where it is needed and\n * costs the image nothing. Scrim for the plate, halo for the glyph.\n */\nexport function getMediaTreatmentLayers(\n value: string,\n anchor: MediaTextAnchor = \"full\",\n): MediaTreatmentLayer[] {\n const treatment = resolveMediaTreatment(value);\n // Nothing over the picture at all. Every other treatment exists to carry\n // type across a photograph; where the scene has no type - a generated clip\n // that is the whole point of the beat - even a vignette is something the\n // viewer did not ask to look through.\n if (treatment === \"none\") return [];\n const vignette: MediaTreatmentLayer = {\n id: \"vignette\",\n background:\n treatment === \"subtle\"\n ? `radial-gradient(ellipse at center, ${easedStops(0.28, 45, 100, \"fade-in\")})`\n : `radial-gradient(ellipse at center, ${easedStops(0.72, 32, 100, \"fade-in\")})`,\n };\n if (treatment === \"subtle\") return [vignette];\n\n const textSafe = treatment === \"text-safe\";\n const layers: MediaTreatmentLayer[] = [vignette];\n\n if (anchor !== \"bottom\") {\n layers.push({\n id: \"center-scrim\",\n background: textSafe\n ? `radial-gradient(ellipse 92% 58% at 50% 50%, ${easedStops(0.46, 34, 90, \"fade-out\")})`\n : `radial-gradient(ellipse 88% 52% at 50% 50%, ${easedStops(0.26, 30, 88, \"fade-out\")})`,\n });\n }\n\n if (anchor !== \"center\") {\n layers.push({\n id: \"bottom-scrim\",\n background: `linear-gradient(to top, ${easedStops(textSafe ? 0.64 : 0.5, 8, 100, \"fade-out\")})`,\n style: { top: \"55%\" },\n });\n }\n\n return layers;\n}\n\n/**\n * Whether the backdrop is actually painting, which is what decides if a scrim\n * is earned. \"pending\" is a browser-only state: static and export renders\n * never run effects and never wait on a network, so they start (and stay)\n * ready and their output is unchanged.\n */\ntype MediaPaintState = \"pending\" | \"ready\" | \"failed\";\n\nfunction initialMediaPaint(\n wantsMedia: boolean,\n resolved: ResolvedMediaType,\n mediaUrl: string,\n mediaPoster: string | undefined,\n): MediaPaintState {\n if (typeof window === \"undefined\") return \"ready\";\n if (!wantsMedia) return \"ready\";\n // A poster paints the video's frame immediately, so the scene is already\n // showing footage even though the stream is still decoding.\n if (resolved === \"video\") return mediaPoster ? \"ready\" : \"pending\";\n if (typeof Image === \"undefined\") return \"ready\";\n // Preloaded or browser-cached media decodes synchronously. Reporting it\n // ready on the first render keeps the common mid-playback case free of a\n // black-then-photo flicker.\n const cached = new Image();\n cached.src = mediaUrl;\n return cached.complete && cached.naturalWidth > 0 ? \"ready\" : \"pending\";\n}\n\nexport function getMediaBackgroundProps(variables: Record<string, unknown>) {\n return {\n mediaUrl: String(variables.mediaUrl || \"\"),\n mediaType: String(variables.mediaType || \"auto\"),\n mediaPoster: String(variables.mediaPoster || \"\"),\n mediaPosition: String(variables.mediaPosition || \"center\"),\n mediaTreatment: String(variables.mediaTreatment || \"cinematic\"),\n };\n}\n\nexport interface SceneBackgroundProps {\n style: TemplateStyle;\n progress: number;\n sceneDuration?: number;\n width: number;\n height: number;\n mediaUrl?: string;\n mediaType?: string;\n /** Still image URL shown while the <video> backdrop decodes its first\n * frame. Without it the element renders transparent during the\n * decode window and the black base shows through. */\n mediaPoster?: string;\n /** Cover-crop focal anchor. Keeps the important edge/subject visible. */\n mediaPosition?: string;\n /** Overlay recipe: subtle, cinematic, or stronger text-safe contrast. */\n mediaTreatment?: string;\n /** Where this template's copy sits, so the scrim is shaped to the type\n * instead of to the frame. Defaults to \"full\" (scrim both the middle and\n * the lower third) for templates that have not declared an anchor. */\n textAnchor?: MediaTextAnchor;\n /** Background motion effect (drift / pulse / Ken Burns). Applied to the photo/video. */\n backgroundEffect?: string;\n /** Retained seed input for customer-owned backdrop compositions. */\n seed?: number | string;\n /** Pause video when preview is paused. Defaults to true (export path). */\n isPlaying?: boolean;\n beatIntensity?: number;\n}\n\nexport const SceneBackground: React.FC<SceneBackgroundProps> = ({\n style,\n progress,\n sceneDuration,\n width: _width, // accepted for symmetry; not currently used in render\n height: _height,\n mediaUrl = \"\",\n mediaType = \"auto\",\n mediaPoster,\n mediaPosition = \"center\",\n mediaTreatment = \"cinematic\",\n textAnchor = \"full\",\n backgroundEffect,\n seed,\n isPlaying = true,\n beatIntensity = 0,\n}) => {\n void _width;\n void _height;\n const resolved = resolveMediaType(mediaType, mediaUrl);\n const wantsMedia = resolved !== \"gradient\" && !!mediaUrl;\n const externalVideoBackdrop = useExternalVideoBackdrop();\n const hasExternalVideoBackdrop = externalVideoBackdrop !== false && resolved === \"video\";\n const externalVideoFailed = externalVideoBackdrop === \"fallback\" && resolved === \"video\";\n const externalVideoReady = externalVideoBackdrop === \"ready\" && resolved === \"video\";\n\n // Apply picture and scrim together. A loading or failed asset keeps the\n // fixed black base instead of painting contrast treatment over empty media.\n const [mediaPaint, setMediaPaint] = useState<MediaPaintState>(() =>\n initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster),\n );\n useEffect(() => {\n setMediaPaint(initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster));\n // Video reports its own paint through onLoadedData / onError below.\n if (!wantsMedia || resolved !== \"photo\") return;\n if (typeof Image === \"undefined\") return;\n let cancelled = false;\n const probe = new Image();\n probe.onload = () => {\n if (!cancelled) setMediaPaint(\"ready\");\n };\n probe.onerror = () => {\n if (!cancelled) setMediaPaint(\"failed\");\n };\n probe.src = mediaUrl;\n if (probe.complete) setMediaPaint(probe.naturalWidth > 0 ? \"ready\" : \"failed\");\n return () => {\n cancelled = true;\n probe.onload = null;\n probe.onerror = null;\n };\n }, [mediaUrl, mediaPoster, resolved, wantsMedia]);\n\n // The element stays mounted while pending — that is what loads it. Only a\n // confirmed failure takes it back out.\n const showMedia = wantsMedia && mediaPaint !== \"failed\";\n const showTreatment = wantsMedia && mediaPaint === \"ready\";\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const resolvedTreatment = resolveMediaTreatment(mediaTreatment);\n const treatmentLayers = getMediaTreatmentLayers(resolvedTreatment, textAnchor);\n\n const gradSeed =\n typeof seed === \"number\"\n ? seed\n : typeof seed === \"string\"\n ? seed.split(\"\").reduce((acc, c) => acc + c.charCodeAt(0), 0)\n : 0;\n\n const bgTransform = getBackgroundTransform(\n backgroundEffect,\n progress,\n beatIntensity,\n );\n\n return (\n <>\n {(!hasExternalVideoBackdrop || externalVideoFailed) && (\n <BrandGradientOverlay\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n seed={gradSeed}\n />\n )}\n\n {showMedia && !hasExternalVideoBackdrop &&\n (resolved === \"video\" ? (\n <SceneVideoBackdrop\n mediaUrl={mediaUrl}\n mediaPoster={mediaPoster}\n mediaPosition={mediaPosition}\n backgroundEffect={backgroundEffect}\n progress={progress}\n beatIntensity={beatIntensity}\n isPlaying={isPlaying}\n onReady={() => setMediaPaint(\"ready\")}\n onError={() => setMediaPaint(\"failed\")}\n />\n ) : (\n <img\n src={mediaUrl}\n alt=\"\"\n aria-hidden=\"true\"\n draggable={false}\n data-media-position={mediaPosition}\n style={{\n position: \"absolute\",\n inset: 0,\n transform: bgTransform.transform,\n transformOrigin: bgTransform.transformOrigin,\n width: \"100%\",\n height: \"100%\",\n objectFit: \"cover\",\n objectPosition: resolvedPosition,\n }}\n />\n ))}\n\n {(hasExternalVideoBackdrop ? externalVideoReady : showTreatment) && !externalVideoFailed &&\n treatmentLayers.map((layer) => (\n <div\n key={layer.id}\n data-media-treatment={resolvedTreatment}\n data-media-overlay={layer.id}\n style={{\n position: \"absolute\",\n inset: 0,\n background: layer.background,\n pointerEvents: \"none\",\n ...layer.style,\n }}\n />\n ))}\n </>\n );\n};\n"
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
35
|
"path": "src/visual-system/scene-templates/media-source.ts",
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
"name": "comparison",
|
|
4
4
|
"type": "registry:block",
|
|
5
5
|
"title": "Comparison",
|
|
6
|
-
"description": "Two short statements with equal weight
|
|
6
|
+
"description": "Two short statements with equal weight over relevant media or black, side by side or stacked in portrait.",
|
|
7
7
|
"dependencies": [
|
|
8
8
|
"react"
|
|
9
9
|
],
|
|
10
10
|
"registryDependencies": [
|
|
11
|
+
"@vanillasky/backgrounds",
|
|
12
|
+
"@vanillasky/motion",
|
|
11
13
|
"@vanillasky/template-context",
|
|
12
14
|
"@vanillasky/theme"
|
|
13
15
|
],
|
|
@@ -16,7 +18,7 @@
|
|
|
16
18
|
"path": "src/visual-system/scene-templates/comparison.tsx",
|
|
17
19
|
"type": "registry:component",
|
|
18
20
|
"target": "vanillasky/scene-templates/comparison.tsx",
|
|
19
|
-
"content": "import type {SceneTemplateProps} from './types';\nimport {
|
|
21
|
+
"content": "import {EditorialSurface} from './editorial-background';\nimport {hasSceneMedia} from './media-source';\nimport type {SceneTemplateProps} from './types';\nimport {editorialLabel,fade} from './editorial-typography';\n\n/** Two concise alternatives, with equal visual weight and a quiet sequential reveal. */\nfunction ComparisonScene(props:SceneTemplateProps){\n const {variables,width,height,progress,motionProgress=progress,safeZone}=props;\n const overMedia=hasSceneMedia(variables);\n const portrait=height>width,u=Math.min(width,height);\n const side=Math.max(width*.12,safeZone.left,safeZone.right);\n const top=Math.max(height*.2,safeZone.top),bottom=height-Math.max(height*.23,safeZone.bottom);\n const area=bottom-top,cy=(top+bottom)/2,available=width-side*2;\n const entries=[{label:String(variables.leftLabel??''),text:String(variables.leftText??'')},{label:String(variables.rightLabel??''),text:String(variables.rightText??'')}];\n const textWidth=portrait?available:available*.39;\n return <EditorialSurface {...props} template=\"comparison\">\n <div aria-hidden=\"true\" style={{position:'absolute',left:portrait?width*.32:width/2,top:portrait?cy:top+area*.18,width:portrait?width*.36:u*.001,height:portrait?u*.001:area*.64,background:'#fff',opacity:.22*fade((motionProgress-.16)/.18)}}/>\n {entries.map((entry,i)=><div key={i} data-comparison-side={i===0?'left':'right'} style={{position:'absolute',left:portrait?side:side+available*(i===0?.055:.555),top:portrait?top+area*(i===0?.25:.75):cy,width:textWidth,transform:'translateY(-50%)',textAlign:'center',opacity:fade((motionProgress-.04-i*.22)/.2)}}>\n {entry.label&&<div style={{...editorialLabel(u),color:overMedia?'#fff':'#b7b7bc',marginBottom:u*.028}}>{entry.label}</div>}\n <div style={{fontSize:u*.058,fontWeight:500,lineHeight:1.22,letterSpacing:'-.025em',textWrap:'balance',overflowWrap:'anywhere'}}>{entry.text}</div>\n </div>)}\n </EditorialSurface>;\n}\n\nexport const ComparisonSceneTemplate = ComparisonScene;\n"
|
|
20
22
|
},
|
|
21
23
|
{
|
|
22
24
|
"path": "src/visual-system/scene-templates/types.ts",
|
|
@@ -29,6 +31,24 @@
|
|
|
29
31
|
"type": "registry:component",
|
|
30
32
|
"target": "vanillasky/scene-templates/editorial-typography.ts",
|
|
31
33
|
"content": "/** Shared typography for quiet editorial scenes. */\nexport const editorialFont = '-apple-system, BlinkMacSystemFont, \"Helvetica Neue\", Roboto, Arial, sans-serif';\nexport const fade = (value: number) => { const t = Math.max(0, Math.min(1, value)); return t * t * (3 - 2 * t); };\n\nexport const editorialLabel = (unit: number) => ({fontSize:unit*.042,fontWeight:400,color:'#b7b7bc',lineHeight:1.25} as const);\n"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"path": "src/visual-system/scene-templates/editorial-background.tsx",
|
|
37
|
+
"type": "registry:component",
|
|
38
|
+
"target": "vanillasky/scene-templates/editorial-background.tsx",
|
|
39
|
+
"content": "import type {CSSProperties, ReactNode} from 'react';\nimport type {SceneTemplateProps} from './types';\nimport {SceneBackground, getMediaBackgroundProps, hasSceneMedia} from './scene-background';\nimport {editorialFont} from './editorial-typography';\n\n/** Optional host-resolved footage, sharing the player's decode and playback plane. */\nexport function EditorialSurface({children,template,...props}:SceneTemplateProps & {children:ReactNode;template:string}) {\n const {variables,style,width,height,progress,sceneDuration,isPlaying}=props;\n const overMedia=hasSceneMedia(variables),u=Math.min(width,height);\n const surface:CSSProperties={position:'absolute',inset:0,overflow:'hidden',background:overMedia?'var(--vanillasky-template-surface, #000)':'#000',fontFamily:editorialFont,color:'#fff',\n // Two soft, scale-aware shadows preserve local contrast without outlining type.\n textShadow:overMedia?`0 ${u*.002}px ${u*.008}px rgba(0,0,0,.65), 0 ${u*.006}px ${u*.016}px rgba(0,0,0,.4)`:undefined};\n return <div data-template={template} style={surface}>\n {overMedia&&<SceneBackground style={style} width={width} height={height} progress={progress} sceneDuration={sceneDuration} {...getMediaBackgroundProps(variables)} mediaTreatment=\"cinematic\" textAnchor=\"center\" backgroundEffect=\"none\" isPlaying={isPlaying}/>}\n {children}\n </div>;\n}\n"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"path": "src/visual-system/scene-templates/scene-background.tsx",
|
|
43
|
+
"type": "registry:component",
|
|
44
|
+
"target": "vanillasky/scene-templates/scene-background.tsx",
|
|
45
|
+
"content": "/**\n * Shared photo/video backdrop for built-in and customer-owned templates.\n * Media is host-resolved; this component never searches or generates assets.\n * The base is fixed black. Photos and videos cover it when available, and\n * optional scrims appear only once the media can paint. The cinematic built-ins\n * request no scrim; custom templates can choose a treatment and text anchor.\n * The retained internal \"gradient\" media sentinel selects the black base and\n * ignores mediaUrl. It is not a built-in authoring mode or brand-color control.\n * Video playback can be owned by the player's persistent external backdrop.\n */\n\nimport React, { useEffect, useState } from \"react\";\nimport {\n hasSceneMedia,\n resolveMediaType,\n type ResolvedMediaType,\n} from \"./media-source\";\nimport type { TemplateStyle } from \"../template-context\";\nimport { BrandGradientOverlay } from \"../backgrounds\";\nimport { getBackgroundTransform } from \"../backgrounds\";\nimport { useExternalVideoBackdrop } from \"./external-video-backdrop\";\nimport { resolveMediaPosition, type MediaPosition } from \"./media-position\";\nimport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\n\nexport { hasSceneMedia, resolveMediaType };\nexport { resolveMediaPosition } from \"./media-position\";\nexport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\nexport type { ResolvedMediaType };\n\nexport type { MediaPosition };\nexport type MediaTreatment = \"none\" | \"subtle\" | \"cinematic\" | \"text-safe\";\n\nexport function resolveMediaTreatment(value: string): MediaTreatment {\n return value === \"none\" || value === \"subtle\" || value === \"text-safe\" ? value : \"cinematic\";\n}\n\nexport interface MediaTreatmentLayer {\n id: \"vignette\" | \"center-scrim\" | \"bottom-scrim\";\n background: string;\n style?: React.CSSProperties;\n}\n\n/**\n * Where the template puts its type. The scrim is shaped to the copy, not to\n * the frame: darkening picture the type never touches costs contrast in the\n * photo and buys no legibility. \"full\" is the conservative default for\n * templates that have not declared an anchor.\n */\nexport type MediaTextAnchor = \"center\" | \"bottom\" | \"full\";\n\n/**\n * Smoothstep-sampled alpha stops between `start`% and `end`% of the gradient\n * box, held at full strength before `start` and after `end`.\n *\n * A two-stop `rgba(0,0,0,a) → transparent` scrim ramps alpha linearly, so it\n * ends with a constant slope. Lateral inhibition in the eye amplifies that\n * slope discontinuity into a visible band — the grey bar cutting across the\n * frame that makes an overlay read as an overlay. Smoothstep flattens the\n * curve at both ends, so the scrim holds where the type sits and then leaves\n * without an edge: the same peak coverage over the copy, noticeably less of\n * the picture spent getting there.\n */\nconst SCRIM_STOP_COUNT = 7;\n\nfunction smoothstep(t: number): number {\n return t * t * (3 - 2 * t);\n}\n\nfunction easedStops(\n peakAlpha: number,\n start: number,\n end: number,\n direction: \"fade-out\" | \"fade-in\",\n): string {\n const alphaAt = (t: number): string => {\n const eased = direction === \"fade-out\" ? 1 - smoothstep(t) : smoothstep(t);\n return `rgba(0,0,0,${Number((peakAlpha * eased).toFixed(3))})`;\n };\n const stops: string[] = [];\n if (start > 0) stops.push(`${alphaAt(0)} 0%`);\n for (let i = 0; i < SCRIM_STOP_COUNT; i += 1) {\n const t = i / (SCRIM_STOP_COUNT - 1);\n const position = Number((start + (end - start) * t).toFixed(2));\n stops.push(`${alphaAt(t)} ${position}%`);\n }\n if (end < 100) stops.push(`${alphaAt(1)} 100%`);\n return stops.join(\", \");\n}\n\n/**\n * Export-safe contrast recipes. Overlays only: SVG capture cannot rely on CSS\n * filters, so a blur-behind-text plate is off the table.\n *\n * The scrims deliberately stop short of solving legibility on their own. A\n * uniform darkening strong enough to carry white type over a blown-out sky\n * needs roughly 0.8 alpha — at that point the photo is a texture, not a\n * picture. The cheaper half of the job belongs to the type: a per-glyph halo\n * (MEDIA_TEXT_SHADOW) buys local contrast exactly where it is needed and\n * costs the image nothing. Scrim for the plate, halo for the glyph.\n */\nexport function getMediaTreatmentLayers(\n value: string,\n anchor: MediaTextAnchor = \"full\",\n): MediaTreatmentLayer[] {\n const treatment = resolveMediaTreatment(value);\n // Nothing over the picture at all. Every other treatment exists to carry\n // type across a photograph; where the scene has no type - a generated clip\n // that is the whole point of the beat - even a vignette is something the\n // viewer did not ask to look through.\n if (treatment === \"none\") return [];\n const vignette: MediaTreatmentLayer = {\n id: \"vignette\",\n background:\n treatment === \"subtle\"\n ? `radial-gradient(ellipse at center, ${easedStops(0.28, 45, 100, \"fade-in\")})`\n : `radial-gradient(ellipse at center, ${easedStops(0.72, 32, 100, \"fade-in\")})`,\n };\n if (treatment === \"subtle\") return [vignette];\n\n const textSafe = treatment === \"text-safe\";\n const layers: MediaTreatmentLayer[] = [vignette];\n\n if (anchor !== \"bottom\") {\n layers.push({\n id: \"center-scrim\",\n background: textSafe\n ? `radial-gradient(ellipse 92% 58% at 50% 50%, ${easedStops(0.46, 34, 90, \"fade-out\")})`\n : `radial-gradient(ellipse 88% 52% at 50% 50%, ${easedStops(0.26, 30, 88, \"fade-out\")})`,\n });\n }\n\n if (anchor !== \"center\") {\n layers.push({\n id: \"bottom-scrim\",\n background: `linear-gradient(to top, ${easedStops(textSafe ? 0.64 : 0.5, 8, 100, \"fade-out\")})`,\n style: { top: \"55%\" },\n });\n }\n\n return layers;\n}\n\n/**\n * Whether the backdrop is actually painting, which is what decides if a scrim\n * is earned. \"pending\" is a browser-only state: static and export renders\n * never run effects and never wait on a network, so they start (and stay)\n * ready and their output is unchanged.\n */\ntype MediaPaintState = \"pending\" | \"ready\" | \"failed\";\n\nfunction initialMediaPaint(\n wantsMedia: boolean,\n resolved: ResolvedMediaType,\n mediaUrl: string,\n mediaPoster: string | undefined,\n): MediaPaintState {\n if (typeof window === \"undefined\") return \"ready\";\n if (!wantsMedia) return \"ready\";\n // A poster paints the video's frame immediately, so the scene is already\n // showing footage even though the stream is still decoding.\n if (resolved === \"video\") return mediaPoster ? \"ready\" : \"pending\";\n if (typeof Image === \"undefined\") return \"ready\";\n // Preloaded or browser-cached media decodes synchronously. Reporting it\n // ready on the first render keeps the common mid-playback case free of a\n // black-then-photo flicker.\n const cached = new Image();\n cached.src = mediaUrl;\n return cached.complete && cached.naturalWidth > 0 ? \"ready\" : \"pending\";\n}\n\nexport function getMediaBackgroundProps(variables: Record<string, unknown>) {\n return {\n mediaUrl: String(variables.mediaUrl || \"\"),\n mediaType: String(variables.mediaType || \"auto\"),\n mediaPoster: String(variables.mediaPoster || \"\"),\n mediaPosition: String(variables.mediaPosition || \"center\"),\n mediaTreatment: String(variables.mediaTreatment || \"cinematic\"),\n };\n}\n\nexport interface SceneBackgroundProps {\n style: TemplateStyle;\n progress: number;\n sceneDuration?: number;\n width: number;\n height: number;\n mediaUrl?: string;\n mediaType?: string;\n /** Still image URL shown while the <video> backdrop decodes its first\n * frame. Without it the element renders transparent during the\n * decode window and the black base shows through. */\n mediaPoster?: string;\n /** Cover-crop focal anchor. Keeps the important edge/subject visible. */\n mediaPosition?: string;\n /** Overlay recipe: subtle, cinematic, or stronger text-safe contrast. */\n mediaTreatment?: string;\n /** Where this template's copy sits, so the scrim is shaped to the type\n * instead of to the frame. Defaults to \"full\" (scrim both the middle and\n * the lower third) for templates that have not declared an anchor. */\n textAnchor?: MediaTextAnchor;\n /** Background motion effect (drift / pulse / Ken Burns). Applied to the photo/video. */\n backgroundEffect?: string;\n /** Retained seed input for customer-owned backdrop compositions. */\n seed?: number | string;\n /** Pause video when preview is paused. Defaults to true (export path). */\n isPlaying?: boolean;\n beatIntensity?: number;\n}\n\nexport const SceneBackground: React.FC<SceneBackgroundProps> = ({\n style,\n progress,\n sceneDuration,\n width: _width, // accepted for symmetry; not currently used in render\n height: _height,\n mediaUrl = \"\",\n mediaType = \"auto\",\n mediaPoster,\n mediaPosition = \"center\",\n mediaTreatment = \"cinematic\",\n textAnchor = \"full\",\n backgroundEffect,\n seed,\n isPlaying = true,\n beatIntensity = 0,\n}) => {\n void _width;\n void _height;\n const resolved = resolveMediaType(mediaType, mediaUrl);\n const wantsMedia = resolved !== \"gradient\" && !!mediaUrl;\n const externalVideoBackdrop = useExternalVideoBackdrop();\n const hasExternalVideoBackdrop = externalVideoBackdrop !== false && resolved === \"video\";\n const externalVideoFailed = externalVideoBackdrop === \"fallback\" && resolved === \"video\";\n const externalVideoReady = externalVideoBackdrop === \"ready\" && resolved === \"video\";\n\n // Apply picture and scrim together. A loading or failed asset keeps the\n // fixed black base instead of painting contrast treatment over empty media.\n const [mediaPaint, setMediaPaint] = useState<MediaPaintState>(() =>\n initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster),\n );\n useEffect(() => {\n setMediaPaint(initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster));\n // Video reports its own paint through onLoadedData / onError below.\n if (!wantsMedia || resolved !== \"photo\") return;\n if (typeof Image === \"undefined\") return;\n let cancelled = false;\n const probe = new Image();\n probe.onload = () => {\n if (!cancelled) setMediaPaint(\"ready\");\n };\n probe.onerror = () => {\n if (!cancelled) setMediaPaint(\"failed\");\n };\n probe.src = mediaUrl;\n if (probe.complete) setMediaPaint(probe.naturalWidth > 0 ? \"ready\" : \"failed\");\n return () => {\n cancelled = true;\n probe.onload = null;\n probe.onerror = null;\n };\n }, [mediaUrl, mediaPoster, resolved, wantsMedia]);\n\n // The element stays mounted while pending — that is what loads it. Only a\n // confirmed failure takes it back out.\n const showMedia = wantsMedia && mediaPaint !== \"failed\";\n const showTreatment = wantsMedia && mediaPaint === \"ready\";\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const resolvedTreatment = resolveMediaTreatment(mediaTreatment);\n const treatmentLayers = getMediaTreatmentLayers(resolvedTreatment, textAnchor);\n\n const gradSeed =\n typeof seed === \"number\"\n ? seed\n : typeof seed === \"string\"\n ? seed.split(\"\").reduce((acc, c) => acc + c.charCodeAt(0), 0)\n : 0;\n\n const bgTransform = getBackgroundTransform(\n backgroundEffect,\n progress,\n beatIntensity,\n );\n\n return (\n <>\n {(!hasExternalVideoBackdrop || externalVideoFailed) && (\n <BrandGradientOverlay\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n seed={gradSeed}\n />\n )}\n\n {showMedia && !hasExternalVideoBackdrop &&\n (resolved === \"video\" ? (\n <SceneVideoBackdrop\n mediaUrl={mediaUrl}\n mediaPoster={mediaPoster}\n mediaPosition={mediaPosition}\n backgroundEffect={backgroundEffect}\n progress={progress}\n beatIntensity={beatIntensity}\n isPlaying={isPlaying}\n onReady={() => setMediaPaint(\"ready\")}\n onError={() => setMediaPaint(\"failed\")}\n />\n ) : (\n <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"
|
|
32
52
|
}
|
|
33
53
|
],
|
|
34
54
|
"meta": {
|
|
@@ -36,7 +56,7 @@
|
|
|
36
56
|
"layer": "template",
|
|
37
57
|
"tier": "free",
|
|
38
58
|
"label": "Comparison",
|
|
39
|
-
"description": "Two short statements with equal weight
|
|
59
|
+
"description": "Two short statements with equal weight over relevant media or black, side by side or stacked in portrait.",
|
|
40
60
|
"usesGlobalTextEffect": false,
|
|
41
61
|
"usesGlobalTransition": false,
|
|
42
62
|
"usesGlobalBackgroundEffect": false,
|
|
@@ -88,13 +108,60 @@
|
|
|
88
108
|
"maxLength": 60,
|
|
89
109
|
"default": "More room to think",
|
|
90
110
|
"description": "Second real alternative, parallel to the first."
|
|
111
|
+
},
|
|
112
|
+
"mediaKeyword": {
|
|
113
|
+
"type": "string",
|
|
114
|
+
"format": "stock-media-keyword",
|
|
115
|
+
"minLength": 1,
|
|
116
|
+
"maxLength": 80,
|
|
117
|
+
"description": "2–8 word literal subject/action search intent, maximum 80 characters. Host resolves URLs.",
|
|
118
|
+
"examples": [
|
|
119
|
+
"Ocean waves breaking on a rocky shore"
|
|
120
|
+
]
|
|
121
|
+
},
|
|
122
|
+
"mediaUrl": {
|
|
123
|
+
"type": "string",
|
|
124
|
+
"format": "uri",
|
|
125
|
+
"description": "Host-only approved photo or video URL.",
|
|
126
|
+
"default": ""
|
|
127
|
+
},
|
|
128
|
+
"mediaType": {
|
|
129
|
+
"type": "string",
|
|
130
|
+
"enum": [
|
|
131
|
+
"photo",
|
|
132
|
+
"video"
|
|
133
|
+
],
|
|
134
|
+
"default": "video",
|
|
135
|
+
"description": "Host-resolved asset kind."
|
|
136
|
+
},
|
|
137
|
+
"mediaPoster": {
|
|
138
|
+
"type": "string",
|
|
139
|
+
"format": "uri",
|
|
140
|
+
"description": "Host-only approved poster URL for video decoding.",
|
|
141
|
+
"default": ""
|
|
142
|
+
},
|
|
143
|
+
"shotDirection": {
|
|
144
|
+
"type": "string",
|
|
145
|
+
"maxLength": 220,
|
|
146
|
+
"description": "Optional action, framing and continuity for generation. Preserve the subject; do not request rendered text."
|
|
147
|
+
},
|
|
148
|
+
"mediaSource": {
|
|
149
|
+
"type": "string",
|
|
150
|
+
"enum": [
|
|
151
|
+
"generate",
|
|
152
|
+
"stock"
|
|
153
|
+
],
|
|
154
|
+
"description": "Generate distinctive illustrative shots; use approved stock for familiar observable subjects."
|
|
91
155
|
}
|
|
92
156
|
},
|
|
93
157
|
"required": [
|
|
94
158
|
"leftText",
|
|
95
159
|
"rightText"
|
|
96
160
|
],
|
|
97
|
-
"additionalProperties": false
|
|
161
|
+
"additionalProperties": false,
|
|
162
|
+
"x-vanillasky": {
|
|
163
|
+
"allowsStockMedia": true
|
|
164
|
+
}
|
|
98
165
|
}
|
|
99
166
|
}
|
|
100
167
|
}
|