itube-specs 0.0.968 → 0.0.970

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.
@@ -4,6 +4,7 @@
4
4
  <UiImg
5
5
  :src="currentImage"
6
6
  :image-urls="currentImageUrls"
7
+ :avif-src="currentAvifSrc"
7
8
  :alt="card.title"
8
9
  class="card-model-mini__img"
9
10
  width="48"
@@ -61,6 +62,7 @@ const currentImage = computed(() => {
61
62
  });
62
63
 
63
64
  const currentImageUrls = computed(() => (imageError.value ? undefined : props.card.primaryImageUrls));
65
+ const currentAvifSrc = computed(() => (imageError.value ? undefined : props.card.primaryImageAvifUrl));
64
66
  </script>
65
67
 
66
68
  <style lang="scss">
@@ -9,6 +9,7 @@
9
9
  <UiImg
10
10
  :src="currentImage"
11
11
  :image-urls="currentImageUrls"
12
+ :avif-src="currentAvifSrc"
12
13
  :loading="loading"
13
14
  :alt="card.title"
14
15
  :priority="priority"
@@ -108,6 +109,7 @@ const currentImage = computed(() => {
108
109
 
109
110
  /** На заглушке ладдер сбрасываем — иначе браузер продолжит тянуть битые кандидаты. */
110
111
  const currentImageUrls = computed(() => (imageError.value ? undefined : props.card.primaryImageUrls));
112
+ const currentAvifSrc = computed(() => (imageError.value ? undefined : props.card.primaryImageAvifUrl));
111
113
 
112
114
  function onImageError() {
113
115
  imageError.value = true;
@@ -7,6 +7,7 @@
7
7
  <UiImg
8
8
  :src="currentImage"
9
9
  :image-urls="currentImageUrls"
10
+ :avif-src="currentAvifSrc"
10
11
  :loading="loading"
11
12
  :alt="card.title"
12
13
  :priority="priority"
@@ -70,6 +71,7 @@ const currentImage = computed(() => {
70
71
  });
71
72
 
72
73
  const currentImageUrls = computed(() => (imageError.value ? undefined : props.card.primaryImageUrls));
74
+ const currentAvifSrc = computed(() => (imageError.value ? undefined : props.card.primaryImageAvifUrl));
73
75
 
74
76
  function onImageError() {
75
77
  imageError.value = true;
@@ -89,8 +89,10 @@ defineProps<{
89
89
  inset: 0;
90
90
  overflow-y: auto;
91
91
 
92
- // полоса прокрутки не должна наезжать на карточки
93
- padding-right: 4px;
92
+ /* Контейнер прокрутки обрезает всё, что элементы рисуют ЗА своей границей, —
93
+ кольцо аватара модели и рамки фокуса. Отступ в 2px даёт им место; справа
94
+ к нему добавлено расстояние до полосы прокрутки. */
95
+ padding: 2px 6px 2px 2px;
94
96
  }
95
97
  }
96
98
  </style>
@@ -8,6 +8,7 @@
8
8
  {'--center': center},
9
9
  {'--open': open},
10
10
  {'--separate': separateLastChild},
11
+ {'--escaped': escaped},
11
12
  ]"
12
13
  class="ui-dropdown"
13
14
  @mouseleave="mouseHandler(false)"
@@ -26,7 +27,10 @@
26
27
  <dialog
27
28
  v-if="useVIf ? open && $slots.items : true"
28
29
  ref="menuRef"
29
- :style="!useVIf ? { display: (open && $slots.items) ? '' : 'none' } : {}"
30
+ :style="[
31
+ !useVIf ? { display: (open && $slots.items) ? '' : 'none' } : {},
32
+ escapedStyle,
33
+ ]"
30
34
  :class="{'--open': open}"
31
35
  class="ui-dropdown__menu"
32
36
  @click="close"
@@ -92,9 +96,75 @@ const { registerOverlay, releaseOverlay } = useOverlayStack();
92
96
  const breakpoints = CSS_BREAKPOINTS;
93
97
  const { isTablet } = checkDeviceWidth(breakpoints);
94
98
 
99
+ /**
100
+ * Выпадашка внутри прокручиваемого блока (боковая колонка плеера, например)
101
+ * обрезается его `overflow` — меню уходит под край. Уводить его в `position: fixed`
102
+ * всегда не стали: у обычных выпадашек координаты считает браузер, и это надёжнее.
103
+ * Поэтому смотрим, есть ли над нами прокручиваемый предок, и только тогда считаем
104
+ * координаты сами. `overflow: hidden` не в счёт — там выпадашки и так живут.
105
+ */
106
+ function hasScrollableAncestor(element: HTMLElement | null): boolean {
107
+ let node = element?.parentElement || null;
108
+
109
+ while (node && node !== document.body) {
110
+ const { overflowX, overflowY } = getComputedStyle(node);
111
+
112
+ if (/auto|scroll/.test(`${overflowX} ${overflowY}`)) return true;
113
+
114
+ node = node.parentElement;
115
+ }
116
+
117
+ return false;
118
+ }
119
+
120
+ /** Меню выведено из потока и позиционируется от экрана. */
121
+ const escaped = ref(false);
122
+ const escapedStyle = ref<Record<string, string>>({});
123
+
124
+ /** Координаты меню от триггера: вниз, а если не влезает — вверх; прижимаем к экрану. */
125
+ function placeEscaped() {
126
+ const trigger = dropdownRef.value?.getBoundingClientRect();
127
+ const menu = menuRef.value;
128
+ if (!trigger || !menu) return;
129
+
130
+ const gap = 4;
131
+ const edge = 8;
132
+ const { offsetWidth: width, offsetHeight: height } = menu;
133
+
134
+ const below = trigger.bottom + gap;
135
+ const flip = below + height > window.innerHeight;
136
+ autoTop.value = flip;
137
+
138
+ let left = props.right ? trigger.right - width : trigger.left;
139
+ if (props.center) left = trigger.left + (trigger.width - width) / 2;
140
+
141
+ escapedStyle.value = {
142
+ top: `${Math.max(edge, flip ? trigger.top - height - gap : below)}px`,
143
+ left: `${Math.min(Math.max(edge, left), window.innerWidth - width - edge)}px`,
144
+ };
145
+ }
146
+
147
+ /** Прокрутка любого предка сдвигает триггер — пересчитываем, пока меню открыто. */
148
+ function onViewportChange() {
149
+ requestAnimationFrame(placeEscaped);
150
+ }
151
+
152
+ function bindViewportListeners(bind: boolean) {
153
+ const method = bind ? 'addEventListener' : 'removeEventListener';
154
+
155
+ window[method]('scroll', onViewportChange, true);
156
+ window[method]('resize', onViewportChange);
157
+ }
158
+
95
159
  function checkPosition() {
96
160
  if (!dropdownRef.value || !menuRef.value || isTablet.value) return;
97
161
  requestAnimationFrame(() => {
162
+ if (escaped.value) {
163
+ placeEscaped();
164
+
165
+ return;
166
+ }
167
+
98
168
  const triggerRect = dropdownRef.value.getBoundingClientRect();
99
169
  const menuHeight = menuRef.value.scrollHeight;
100
170
  const spaceBelow = window.innerHeight - triggerRect.bottom;
@@ -104,6 +174,12 @@ function checkPosition() {
104
174
 
105
175
  function openDropdown() {
106
176
  open.value = !open.value;
177
+
178
+ if (open.value && !isTablet.value) {
179
+ escaped.value = hasScrollableAncestor(dropdownRef.value);
180
+ if (escaped.value) bindViewportListeners(true);
181
+ }
182
+
107
183
  if (open.value) nextTick(() => checkPosition());
108
184
 
109
185
  if (isTablet.value && open.value) {
@@ -117,6 +193,8 @@ function openDropdown() {
117
193
 
118
194
  function close() {
119
195
  open.value = false;
196
+ bindViewportListeners(false);
197
+ escapedStyle.value = {};
120
198
 
121
199
  if (isTablet.value) {
122
200
  nextTick(() => {
@@ -139,6 +217,8 @@ watch(open, (val) => {
139
217
  });
140
218
 
141
219
  onBeforeUnmount(() => {
220
+ bindViewportListeners(false);
221
+
142
222
  if (open.value && isTablet.value) {
143
223
  scrollUnlock();
144
224
  releaseOverlay(menuRef.value);
@@ -278,6 +358,17 @@ defineExpose({ openDropdown, open });
278
358
  }
279
359
  }
280
360
 
361
+ /* Меню внутри прокручиваемого блока: позиционируется от экрана, координаты считает
362
+ скрипт (см. placeEscaped) — браузерные `bottom`/`transform` здесь только мешают. */
363
+ .ui-dropdown.--escaped .ui-dropdown__menu {
364
+ @include from-br(md) {
365
+ position: fixed;
366
+ right: auto;
367
+ bottom: auto;
368
+ transform: none;
369
+ }
370
+ }
371
+
281
372
  .ui-dropdown__menu-items > .ui-dropdown__item {
282
373
  @include font-md;
283
374
 
@@ -1,14 +1,13 @@
1
1
  <template>
2
- <!-- `<picture>` только под avif: обёртка сдвигает картинку на уровень ниже, а
2
+ <!-- `<picture>` только когда есть avif: обёртка сдвигает картинку на уровень ниже, а
3
3
  `card/playlist.vue` и `playlist-mini.vue` целятся в свои UiImg через `:first-child`/`:nth-child(2)`
4
4
  — внутри `<picture>` каждая из них стала бы первым ребёнком, и раскладка поехала бы. -->
5
5
  <picture
6
- v-if="avifSrcset"
6
+ v-if="avifSrc"
7
7
  class="ui-img"
8
8
  >
9
9
  <source
10
- :srcset="avifSrcset"
11
- :sizes="computedSizes"
10
+ :srcset="avifSrc"
12
11
  type="image/avif"
13
12
  >
14
13
  <img
@@ -31,11 +30,13 @@ defineOptions({ inheritAttrs: false });
31
30
 
32
31
  const props = withDefaults(defineProps<{
33
32
  src?: string,
33
+ /** Тот же кадр в avif. Есть — рендерится `<picture>`, `src` становится фолбэком. */
34
+ avifSrc?: string,
34
35
  width: string | number,
35
36
  height: string | number,
36
37
  sizes?: string,
37
38
  urls?: IThumbUrls,
38
- /** Ладдер с avif-веткой (портреты моделей). Старше `urls`. */
39
+ /** Ладдер по ширине (портреты моделей). Старше `urls`. */
39
40
  imageUrls?: IModelImageUrls,
40
41
  alt?: string,
41
42
  loading?: 'lazy' | 'eager',
@@ -53,10 +54,13 @@ const attrs = useAttrs();
53
54
  * (апрель 2026), Safari 27 (сентябрь 2026). Где не поддержан — деградирует в `100vw`,
54
55
  * и браузер берёт самый крупный кандидат. Для ладдеров, где это перерасход, вызывающий
55
56
  * пишет `sizes` сам, начиная с `auto,` — остаток списка и есть фолбэк под Safari ≤26.
56
- * Такой `sizes` отдаём как есть.
57
57
  */
58
58
  const computedSizes = computed(() => {
59
- if (props.sizes?.startsWith('auto')) return props.sizes;
59
+ if (props.sizes?.startsWith('auto')) {
60
+ // `auto` валиден только при loading=lazy. На eager браузер бракует весь атрибут и
61
+ // уходит в дефолтные 100vw, то есть тянет самый крупный кандидат: отдаём хвост списка.
62
+ return props.loading === 'lazy' ? props.sizes : props.sizes.replace(/^auto,\s*/, '');
63
+ }
60
64
  if (props.loading === 'lazy') return 'auto';
61
65
  return props.sizes;
62
66
  });
@@ -69,8 +73,6 @@ const imgSrc = computed(() => {
69
73
 
70
74
  const computedSrcset = computed(() => buildSrcset(props.imageUrls?.webp) || buildSrcset(props.urls?.webp));
71
75
 
72
- const avifSrcset = computed(() => buildSrcset(props.imageUrls?.avif));
73
-
74
76
  const imgAttrs = computed(() => ({
75
77
  ...attrs,
76
78
  src: imgSrc.value,
@@ -1,27 +1,33 @@
1
- import type { IModelImageUrls } from '../types';
2
- import { ModelImageSize } from '../runtime';
3
-
4
1
  export interface ITestModelPortrait {
5
2
  primaryImageUrl: string;
6
3
  thumbUrl: string;
7
- primaryImageUrls?: IModelImageUrls;
4
+ primaryImageAvifUrl?: string;
8
5
  }
9
6
 
10
- const PORTRAIT_COUNT = 25;
7
+ const PORTRAIT_COUNT = 16;
11
8
 
12
9
  /**
13
- * Тестовые портреты моделей из `public/img/models/`. Формат переключается query-параметром —
14
- * для замера веса и сравнения качества на одной и той же странице:
10
+ * Тестовые портреты моделей из `public/img/models/`. Везде один файл 640w:
11
+ *
12
+ * - `?img=png` — исходники;
13
+ * - `?img=webp` — только webp, без avif-ветки;
14
+ * - без параметра — avif q63 4:4:4 через `<picture>`, фолбэк webp `-q 85 -m 6 -pass 10 -sharp_yuv`.
15
15
  *
16
- * - `?img=png` — исходники без ладдера, как было до нарезки;
17
- * - `?img=webp` — ладдер без avif-ветки, чтобы увидеть, сколько даёт именно avif;
18
- * - без параметра — avif + webp.
16
+ * Качество ступеней подобрано по равной ошибке в высоких частотах: webp q85 даёт 0.00405,
17
+ * avif q63 — 0.00399. Вес при этом 645 против 434 KB на 16 картинок, то есть avif легче на
18
+ * треть при том же качестве. Разрыв держится на всём диапазоне, от точки не зависит.
19
19
  *
20
- * Читается один раз на монтировании страницы, поэтому переключение — перезагрузкой,
21
- * а не навигацией по query. Для сравнения сети это и нужно: замер делается с чистого захода.
20
+ * Один файл, а НЕ ступени через srcset. Исходники 640, слот карточки максимум 297 CSS, так
21
+ * что 640w закрывает любое устройство вплоть до DPR3. Ступень мельче слота при этом заведомо
22
+ * хуже полного файла: её портит двойной пересчёт (наш ресайз плюс браузерный), а её артефакты
23
+ * показываются почти 1:1, тогда как 640w в том же слоте ужимается в 2.6 раза и они
24
+ * размазываются. Проверено на DPR1-мониторе: ладдер мылит, один файл нет. Ступени станут
25
+ * осмысленны, только если исходники вырастут заметно выше 640.
22
26
  *
23
- * Исходники разнокалиберные: portrait-1..16 — 640×960, portrait-17..25 — 1000×1200. В режиме
24
- * `png` это видно, нарезка же у всех одинаковая, так что сравнивать с `png` надо с поправкой.
27
+ * Кроп под 3:4 не делаем — обрезает `object-fit`. Почему именно так, см. `IModelImageUrls`.
28
+ *
29
+ * Читается один раз на монтировании, поэтому переключение — перезагрузкой, а не навигацией
30
+ * по query. Для замера сети это и нужно: он честен только с чистого захода.
25
31
  */
26
32
  export function useTestModelImages() {
27
33
  const format = useRoute().query['img'];
@@ -30,25 +36,17 @@ export function useTestModelImages() {
30
36
  const base = `/img/models/portrait-${index + 1}`;
31
37
 
32
38
  if (format === 'png') {
33
- return { primaryImageUrl: `${base}.png`, thumbUrl: `${base}.png`, primaryImageUrls: undefined };
39
+ return { primaryImageUrl: `${base}.png`, thumbUrl: `${base}.png` };
34
40
  }
35
41
 
36
- const ladder = (extension: string) => ({
37
- [ModelImageSize.Small]: `${base}-300.${extension}`,
38
- [ModelImageSize.Medium]: `${base}-600.${extension}`,
39
- });
40
-
41
42
  return {
42
- primaryImageUrl: `${base}-600.webp`,
43
- thumbUrl: `${base}-300.webp`,
44
- primaryImageUrls: {
45
- avif: format === 'webp' ? undefined : ladder('avif'),
46
- webp: ladder('webp'),
47
- },
43
+ primaryImageUrl: `${base}-q85.webp`,
44
+ thumbUrl: `${base}-q85.webp`,
45
+ primaryImageAvifUrl: format === 'webp' ? undefined : `${base}-q63.avif`,
48
46
  };
49
47
  });
50
48
 
51
- const portraitAt = (index: number) => portraits[index % PORTRAIT_COUNT]!;
49
+ const portraitAt = (index: number) => portraits[index % portraits.length]!;
52
50
 
53
51
  return { portraits, portraitAt };
54
52
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.968",
4
+ "version": "0.0.970",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -51,7 +51,7 @@ const { meta, h1 } = useMeta({
51
51
  t,
52
52
  pageKey: pagePureName,
53
53
  brandName: useAppConfig().brandName,
54
- sortOptions: vectorSearch ? [] : sortItemsSearch,
54
+ sortOptions: vectorSearch ? sortItemsSearch.slice(0, 1) : sortItemsSearch,
55
55
  text: metaTitle,
56
56
  });
57
57
 
package/runtime/index.ts CHANGED
@@ -8,7 +8,6 @@ export * from './constants/niche';
8
8
  export * from './constants/report-forms-subjects';
9
9
  export * from './constants/playlist-step';
10
10
  export * from './constants/thumb-size';
11
- export * from './constants/model-image-size';
12
11
  export * from './constants/webcams';
13
12
  export * from './utils/build-srcset';
14
13
  export * from './utils/format-date';
@@ -2,6 +2,13 @@ import { describe, it, expect } from 'vitest';
2
2
  import { buildSrcset } from '../build-srcset';
3
3
 
4
4
  describe('buildSrcset', () => {
5
+ it('ключ-ширина без высоты — основная форма для портретов', () => {
6
+ expect(buildSrcset({
7
+ 300: 'https://img.com/a-300.webp',
8
+ 640: 'https://img.com/a-640.webp',
9
+ })).toBe('https://img.com/a-300.webp 300w, https://img.com/a-640.webp 640w');
10
+ });
11
+
5
12
  it('собирает дескрипторы ширины из ключей карты', () => {
6
13
  expect(buildSrcset({
7
14
  '300x400': 'https://img.com/a-300.webp',
@@ -26,26 +26,12 @@ describe('cleanModelCard', () => {
26
26
  expect(result.country).toEqual({ title: 'United States of America', name: 'us' });
27
27
  });
28
28
 
29
- it('primary_image_urls → ладдер портрета, недостающий размер пустой строкой', () => {
29
+ it('primary_image_urls → ладдер портрета как есть, ступени произвольные', () => {
30
30
  const raw = makeRaw({
31
- primary_image_urls: { webp: { '600x800': 'https://img.com/p-600.webp' } },
31
+ primary_image_urls: { webp: { 300: 'https://img.com/p-300.webp', 640: 'https://img.com/p-640.webp' } },
32
32
  });
33
33
  expect(cleanModelCard(raw as any).primaryImageUrls).toEqual({
34
- avif: undefined,
35
- webp: { '300x400': '', '600x800': 'https://img.com/p-600.webp' },
36
- });
37
- });
38
-
39
- it('avif-ветка попадает в ладдер', () => {
40
- const raw = makeRaw({
41
- primary_image_urls: {
42
- avif: { '300x400': 'https://img.com/p-300.avif', '600x800': 'https://img.com/p-600.avif' },
43
- webp: { '300x400': 'https://img.com/p-300.webp', '600x800': 'https://img.com/p-600.webp' },
44
- },
45
- });
46
- expect(cleanModelCard(raw as any).primaryImageUrls?.avif).toEqual({
47
- '300x400': 'https://img.com/p-300.avif',
48
- '600x800': 'https://img.com/p-600.avif',
34
+ webp: { 300: 'https://img.com/p-300.webp', 640: 'https://img.com/p-640.webp' },
49
35
  });
50
36
  });
51
37
 
@@ -1,12 +1,7 @@
1
1
  import type { IRawModelCard, IModelCard, IModelParameter } from '../../../types';
2
2
  import { ThumbSize } from '../../constants/thumb-size';
3
- import { ModelImageSize } from '../../constants/model-image-size';
4
3
 
5
4
  const parameterNames = ['guy_type', 'ethnicity'];
6
- const pickPortraitSizes = (urls?: Partial<Record<ModelImageSize, string>>) => urls && {
7
- [ModelImageSize.Small]: urls[ModelImageSize.Small] || '',
8
- [ModelImageSize.Medium]: urls[ModelImageSize.Medium] || '',
9
- };
10
5
  const firstValue = (card: IRawModelCard, name: string) => card.parameters?.find(item => item.name === name)?.values?.[0];
11
6
  const age = (card: IRawModelCard) => firstValue(card, 'age')?.title;
12
7
 
@@ -16,10 +11,7 @@ export const cleanModelCard = (card: IRawModelCard): IModelCard => ({
16
11
  videosCount: card.videos_count || 0,
17
12
  thumbUrl: card.video_thumb_urls?.webp?.[ThumbSize.Small] || '',
18
13
  primaryImageUrl: card.primary_image_url || '',
19
- primaryImageUrls: card.primary_image_urls?.webp ? {
20
- avif: pickPortraitSizes(card.primary_image_urls.avif),
21
- webp: pickPortraitSizes(card.primary_image_urls.webp)!,
22
- } : undefined,
14
+ primaryImageUrls: card.primary_image_urls?.webp ? { webp: card.primary_image_urls.webp } : undefined,
23
15
  guid: card.guid || '',
24
16
  // Страна лежит в parameters под именем nationality_iso_codes: `name` — код ISO-2 (`us`),
25
17
  // он же имя файла флага, `title` — читаемое название. Одноимённое поле верхнего уровня
@@ -7,6 +7,7 @@ export function convertModelToRoundCard(model: IModelCard): IRoundCard {
7
7
  title: model.title,
8
8
  primaryImageUrl: model.primaryImageUrl || model.thumbUrl || '',
9
9
  primaryImageUrls: model.primaryImageUrls,
10
+ primaryImageAvifUrl: model.primaryImageAvifUrl,
10
11
  videosCount: model.videosCount,
11
12
  link: `/models/${convertString().toSlug(model.title)}`,
12
13
  };
@@ -5,6 +5,8 @@ export interface IModelCard {
5
5
  videosCount: number;
6
6
  thumbUrl: string;
7
7
  primaryImageUrl?: string;
8
+ /** Тот же портрет в avif. Есть — карточка отдаёт его через `<picture>`, webp фолбэком. */
9
+ primaryImageAvifUrl?: string;
8
10
  /** Ладдер портрета под srcset. Нет — карточка рендерит один `primaryImageUrl`. */
9
11
  primaryImageUrls?: IModelImageUrls;
10
12
  guid: string;
@@ -1,7 +1,13 @@
1
- import type { ModelImageSize } from '../runtime/constants/model-image-size';
2
-
3
- /** Очищенный портрет модели, ключ `<ширина>x<высота>`. `avif` необязателен — webp несущий. */
1
+ /**
2
+ * Ладдер портрета модели. Ключ — ширина в пикселях, высота не фиксирована.
3
+ *
4
+ * Портреты НЕ приводятся к 3:4 нарезкой — обрезает `object-fit` в карточке. Предварительный
5
+ * кроп менял габариты файла, а вместе с ними и цепочку уменьшения в браузере, и картинка
6
+ * заметно мылила при тех же самых пикселях. Проверено на живой вёрстке.
7
+ *
8
+ * Только webp: avif на портретах проигрывал по точности и почти не выигрывал по весу, а
9
+ * держать ради него `<picture>` в `UiImg` не окупалось.
10
+ */
4
11
  export interface IModelImageUrls {
5
- avif?: Partial<Record<ModelImageSize, string>>;
6
- webp: Partial<Record<ModelImageSize, string>>;
12
+ webp: Record<string, string>;
7
13
  }
@@ -1,8 +1,5 @@
1
- import type { ModelImageSize } from '../../runtime/constants/model-image-size';
2
-
3
- /** Raw портрет модели из API — зеркало `video_thumb_urls`, но с портретными размерами */
1
+ /** Raw портрет модели из API. Ключ — ширина в пикселях, см. `IModelImageUrls`. */
4
2
  export interface IRawModelImageUrls {
5
- jpeg?: Partial<Record<ModelImageSize, string>>;
6
- webp?: Partial<Record<ModelImageSize, string>>;
7
- avif?: Partial<Record<ModelImageSize, string>>;
3
+ jpeg?: Record<string, string>;
4
+ webp?: Record<string, string>;
8
5
  }
@@ -3,6 +3,8 @@ import type { IModelImageUrls } from './model-image-urls';
3
3
  export interface IRoundCard {
4
4
  title: string
5
5
  primaryImageUrl: string
6
+ /** Тот же портрет в avif. Есть — карточка отдаёт его через `<picture>`, webp фолбэком. */
7
+ primaryImageAvifUrl?: string
6
8
  /** Ладдер портрета под srcset. Нет — карточка рендерит один `primaryImageUrl`. */
7
9
  primaryImageUrls?: IModelImageUrls
8
10
  videosCount: number
@@ -1,6 +0,0 @@
1
- export const ModelImageSize = {
2
- Small: '300x400',
3
- Medium: '600x800',
4
- } as const;
5
-
6
- export type ModelImageSize = typeof ModelImageSize[keyof typeof ModelImageSize];