itube-specs 0.0.967 → 0.0.969

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.
Files changed (37) hide show
  1. package/components/card/model-mini.vue +4 -1
  2. package/components/card/model.vue +12 -2
  3. package/components/card/round.vue +8 -1
  4. package/components/info/main.vue +9 -1
  5. package/components/player/aside-videos.vue +4 -2
  6. package/components/search/item.vue +2 -1
  7. package/components/ui/dropdown.vue +92 -1
  8. package/components/ui/img.vue +58 -19
  9. package/components/view/video.vue +2 -2
  10. package/components/view/videos.vue +6 -1
  11. package/composables/fetch/use-fetch-vector-videos-search.ts +37 -0
  12. package/composables/use-test-model-images.ts +54 -0
  13. package/package.json +1 -1
  14. package/pages/models/[slug]/index.vue +4 -2
  15. package/pages/models/index.vue +6 -13
  16. package/pages/search/[slug]/index.vue +5 -1
  17. package/pages/search/[slug]/videos.vue +9 -3
  18. package/runtime/constants/async-data.ts +1 -0
  19. package/runtime/constants/model-image-size.ts +6 -0
  20. package/runtime/index.ts +2 -0
  21. package/runtime/utils/__tests__/build-srcset.test.ts +24 -0
  22. package/runtime/utils/build-srcset.ts +14 -0
  23. package/runtime/utils/cleaners/__tests__/clean-model-card.test.ts +27 -0
  24. package/runtime/utils/cleaners/clean-model-card.ts +9 -0
  25. package/runtime/utils/converters/__tests__/convert-model-to-round-card.test.ts +26 -0
  26. package/runtime/utils/converters/convert-model-to-round-card.ts +1 -0
  27. package/server/api/videos/vector-search.post.ts +30 -0
  28. package/services/api/videos.service.ts +28 -0
  29. package/stubs/library-follows.ts +15 -15
  30. package/types/card-info.d.ts +3 -0
  31. package/types/index.d.ts +2 -0
  32. package/types/model-card.d.ts +4 -0
  33. package/types/model-image-urls.d.ts +7 -0
  34. package/types/raw/raw-model-card.d.ts +2 -0
  35. package/types/raw/raw-model-image-urls.d.ts +8 -0
  36. package/types/round-card.d.ts +4 -0
  37. package/types/search-top-models.d.ts +4 -0
@@ -3,11 +3,12 @@
3
3
  <div class="card-model-mini">
4
4
  <UiImg
5
5
  :src="currentImage"
6
+ :image-urls="currentImageUrls"
6
7
  :alt="card.title"
7
8
  class="card-model-mini__img"
8
9
  width="48"
9
10
  height="48"
10
- sizes="48px"
11
+ sizes="auto, 48px"
11
12
  @error="imageError = true"
12
13
  />
13
14
  <div class="card-model-mini__wrapper">
@@ -58,6 +59,8 @@ const currentImage = computed(() => {
58
59
  if (imageError.value) return fallbackImage;
59
60
  return props.card.primaryImageUrl || props.card.thumbUrl || fallbackImage;
60
61
  });
62
+
63
+ const currentImageUrls = computed(() => (imageError.value ? undefined : props.card.primaryImageUrls));
61
64
  </script>
62
65
 
63
66
  <style lang="scss">
@@ -8,10 +8,11 @@
8
8
  <div class="card-model__thumb">
9
9
  <UiImg
10
10
  :src="currentImage"
11
+ :image-urls="currentImageUrls"
11
12
  :loading="loading"
12
13
  :alt="card.title"
13
14
  :priority="priority"
14
- :sizes="`(max-width: ${CSS_BREAKPOINTS.xs}px) 50vw, 240px`"
15
+ :sizes="SIZES"
15
16
  class="card-model__img"
16
17
  width="240"
17
18
  height="320"
@@ -77,7 +78,6 @@
77
78
 
78
79
  <script setup lang="ts">
79
80
  import { convertString, formatDate } from '../../runtime';
80
- import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
81
81
  import type { IModelCard } from '../../types';
82
82
 
83
83
  const props = withDefaults(defineProps<{
@@ -91,6 +91,13 @@ const props = withDefaults(defineProps<{
91
91
  const { generateLink } = useGenerateLink();
92
92
  const { t } = useI18n();
93
93
 
94
+ /**
95
+ * Пороги — колоночная лестница `.grid-models` (2 → 3 с 640px → … → 6 с 1280px), а не `$brkpnts`.
96
+ * Слот от 640px и выше держится в 206–249px, поэтому один `250px` покрывает всю правую часть.
97
+ * `auto` впереди — см. `UiImg`.
98
+ */
99
+ const SIZES = 'auto, (max-width: 639px) 50vw, 250px';
100
+
94
101
  const fallbackImage = '/img/model-placeholder.webp';
95
102
  const imageError = ref(false);
96
103
 
@@ -99,6 +106,9 @@ const currentImage = computed(() => {
99
106
  return props.card.primaryImageUrl || props.card.thumbUrl || fallbackImage;
100
107
  });
101
108
 
109
+ /** На заглушке ладдер сбрасываем — иначе браузер продолжит тянуть битые кандидаты. */
110
+ const currentImageUrls = computed(() => (imageError.value ? undefined : props.card.primaryImageUrls));
111
+
102
112
  function onImageError() {
103
113
  imageError.value = true;
104
114
  }
@@ -6,13 +6,14 @@
6
6
  >
7
7
  <UiImg
8
8
  :src="currentImage"
9
+ :image-urls="currentImageUrls"
9
10
  :loading="loading"
10
11
  :alt="card.title"
11
12
  :priority="priority"
13
+ :sizes="SIZES"
12
14
  class="card-round__img"
13
15
  width="96"
14
16
  height="96"
15
- sizes="96px"
16
17
  @error="onImageError"
17
18
  />
18
19
  <span
@@ -40,6 +41,7 @@
40
41
  </template>
41
42
 
42
43
  <script setup lang="ts">
44
+ import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
43
45
  import type { IRoundCard } from '../../types';
44
46
 
45
47
  const props = withDefaults(defineProps<{
@@ -56,6 +58,9 @@ const props = withDefaults(defineProps<{
56
58
  const { t } = useI18n();
57
59
  const { generateLink } = useGenerateLink();
58
60
 
61
+ /** `--card-round-size`: 80px до sm, 96px дальше. `auto` впереди — см. `UiImg`. */
62
+ const SIZES = `auto, (max-width: ${CSS_BREAKPOINTS.sm - 1}px) 80px, 96px`;
63
+
59
64
  const fallbackImage = props.placeholderAvatar;
60
65
  const imageError = ref(false);
61
66
 
@@ -64,6 +69,8 @@ const currentImage = computed(() => {
64
69
  return props.card.primaryImageUrl || fallbackImage || '';
65
70
  });
66
71
 
72
+ const currentImageUrls = computed(() => (imageError.value ? undefined : props.card.primaryImageUrls));
73
+
67
74
  function onImageError() {
68
75
  imageError.value = true;
69
76
  }
@@ -15,11 +15,12 @@
15
15
  <UiImg
16
16
  v-if="card.img"
17
17
  :src="currentImage"
18
+ :image-urls="currentImageUrls"
18
19
  :alt="title"
20
+ :sizes="IMG_SIZES"
19
21
  class="info-main__img"
20
22
  width="80"
21
23
  height="80"
22
- sizes="80px"
23
24
  @error="imageError = true"
24
25
  />
25
26
 
@@ -157,6 +158,7 @@ import type {
157
158
  IChannelCard,
158
159
  ICategoryCard
159
160
  } from '../../types';
161
+ import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
160
162
  import { AuthorizationApiService } from '../../services/api/authorization.service';
161
163
  import { groupParametersByGroup } from '../../utils/info-main/group-parameters-by-group';
162
164
  import { isoCodeToCountryName } from '../../utils/iso-code-to-country-name';
@@ -210,6 +212,11 @@ const currentImage = computed(() => {
210
212
  return props.card.img || placeholder.value;
211
213
  });
212
214
 
215
+ /** `.info-main__img` — 80px до sm, 96px дальше. `auto` впереди — см. `UiImg`. */
216
+ const IMG_SIZES = `auto, (max-width: ${CSS_BREAKPOINTS.sm - 1}px) 80px, 96px`;
217
+
218
+ const currentImageUrls = computed(() => (imageError.value ? undefined : props.card.imgUrls));
219
+
213
220
  function getParameter(name: string) {
214
221
  return props.card.parameters?.find(p => p.name === name)?.values?.[ 0 ]?.name;
215
222
  }
@@ -231,6 +238,7 @@ const shareModelCard = computed<IModelCard>(() => ({
231
238
  title: props.card.title,
232
239
  videosCount: props.card.videosCount,
233
240
  thumbUrl: props.card.img || '',
241
+ primaryImageUrls: props.card.imgUrls,
234
242
  guid: props.card.guid || '',
235
243
  parameters: [],
236
244
  country: shareCountry.value,
@@ -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>
@@ -11,9 +11,10 @@
11
11
  <UiImg
12
12
  v-if="isModel"
13
13
  :src="item?.primaryImageUrl || `/img/model-placeholder.webp`"
14
+ :image-urls="item?.primaryImageUrls"
14
15
  :alt="title"
15
16
  class="search-item__icon"
16
- sizes="32px"
17
+ sizes="auto, 32px"
17
18
  width="32"
18
19
  height="32"
19
20
  />
@@ -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,21 +1,33 @@
1
1
  <template>
2
+ <!-- `<picture>` только под avif: обёртка сдвигает картинку на уровень ниже, а
3
+ `card/playlist.vue` и `playlist-mini.vue` целятся в свои UiImg через `:first-child`/`:nth-child(2)`
4
+ — внутри `<picture>` каждая из них стала бы первым ребёнком, и раскладка поехала бы. -->
5
+ <picture
6
+ v-if="avifSrcset"
7
+ class="ui-img"
8
+ >
9
+ <source
10
+ :srcset="avifSrcset"
11
+ :sizes="computedSizes"
12
+ type="image/avif"
13
+ >
14
+ <img
15
+ v-bind="imgAttrs"
16
+ @error="emit('error', $event)"
17
+ >
18
+ </picture>
2
19
  <img
3
- v-bind="$attrs"
4
- :src="imgSrc"
5
- :srcset="srcset"
6
- :loading="loading"
7
- :width="width"
8
- :height="height"
9
- :sizes="computedSizes"
10
- :alt="alt"
11
- :fetchpriority="priority ? 'high' : 'auto'"
20
+ v-else
21
+ v-bind="imgAttrs"
12
22
  @error="emit('error', $event)"
13
23
  >
14
24
  </template>
15
25
 
16
26
  <script setup lang="ts">
17
- import type { IThumbUrls } from '../../types';
18
- import { ThumbSize } from '../../runtime';
27
+ import type { IThumbUrls, IModelImageUrls } from '../../types';
28
+ import { ThumbSize, buildSrcset } from '../../runtime';
29
+
30
+ defineOptions({ inheritAttrs: false });
19
31
 
20
32
  const props = withDefaults(defineProps<{
21
33
  src?: string,
@@ -23,6 +35,8 @@ const props = withDefaults(defineProps<{
23
35
  height: string | number,
24
36
  sizes?: string,
25
37
  urls?: IThumbUrls,
38
+ /** Ладдер с avif-веткой (портреты моделей). Старше `urls`. */
39
+ imageUrls?: IModelImageUrls,
26
40
  alt?: string,
27
41
  loading?: 'lazy' | 'eager',
28
42
  fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside',
@@ -32,7 +46,17 @@ const props = withDefaults(defineProps<{
32
46
  fit: 'cover',
33
47
  });
34
48
 
49
+ const attrs = useAttrs();
50
+
51
+ /**
52
+ * `sizes="auto"` точнее любого авторского списка, но молодой: Chrome 126, Firefox 150
53
+ * (апрель 2026), Safari 27 (сентябрь 2026). Где не поддержан — деградирует в `100vw`,
54
+ * и браузер берёт самый крупный кандидат. Для ладдеров, где это перерасход, вызывающий
55
+ * пишет `sizes` сам, начиная с `auto,` — остаток списка и есть фолбэк под Safari ≤26.
56
+ * Такой `sizes` отдаём как есть.
57
+ */
35
58
  const computedSizes = computed(() => {
59
+ if (props.sizes?.startsWith('auto')) return props.sizes;
36
60
  if (props.loading === 'lazy') return 'auto';
37
61
  return props.sizes;
38
62
  });
@@ -43,16 +67,31 @@ const imgSrc = computed(() => {
43
67
  return undefined;
44
68
  });
45
69
 
46
- const srcset = computed(() => {
47
- if (!props.urls) return undefined;
48
- const entries = Object.entries(props.urls.webp);
49
- if (!entries.length) return undefined;
50
- return entries
51
- .map(([size, url]) => `${url} ${size.split('x')[0]}w`)
52
- .join(', ');
53
- });
70
+ const computedSrcset = computed(() => buildSrcset(props.imageUrls?.webp) || buildSrcset(props.urls?.webp));
71
+
72
+ const avifSrcset = computed(() => buildSrcset(props.imageUrls?.avif));
73
+
74
+ const imgAttrs = computed(() => ({
75
+ ...attrs,
76
+ src: imgSrc.value,
77
+ srcset: computedSrcset.value,
78
+ loading: props.loading,
79
+ width: props.width,
80
+ height: props.height,
81
+ sizes: computedSizes.value,
82
+ alt: props.alt,
83
+ fetchpriority: props.priority ? 'high' : 'auto',
84
+ }));
54
85
 
55
86
  const emit = defineEmits<{
56
87
  (e: 'error', event: Event): void
57
88
  }>();
58
89
  </script>
90
+
91
+ <style lang="scss">
92
+ /* Обёртка не должна участвовать в раскладке: у потребителей вроде `.card-model__img` стоит
93
+ `height: 100%`, и оно должно меряться от того же родителя, что и раньше, а не от `<picture>`. */
94
+ .ui-img {
95
+ display: contents;
96
+ }
97
+ </style>
@@ -203,14 +203,14 @@ const topChips = computed(() => {
203
203
 
204
204
  const topChipsStrings = computed(() => topChips.value?.map(item => item.value));
205
205
 
206
- const testModelImages = Array.from({ length: 16 }, (_, index) => `/img/models/portrait-${index + 1}.png`);
206
+ const { portraitAt } = useTestModelImages();
207
207
 
208
208
  function getModelChips(model: IVideoData['models'][number], index: number): IChipsItem {
209
209
  return {
210
210
  prefix: '/models',
211
211
  value: model.title,
212
212
  title: model.title,
213
- image: testModelImages[index % testModelImages.length],
213
+ image: portraitAt(index).thumbUrl,
214
214
  };
215
215
  }
216
216
  </script>
@@ -11,6 +11,7 @@
11
11
  icon="video-camera"
12
12
  />
13
13
  <LazySectionToolbar
14
+ v-if="!hideToolbar"
14
15
  :sort-items="sortItems"
15
16
  class="view-videos__options"
16
17
  hydrate-on-idle
@@ -61,7 +62,7 @@
61
62
  <UiEmptyState v-else/>
62
63
 
63
64
  <LazyUiPagination
64
- v-if="data"
65
+ v-if="data && !hidePagination"
65
66
  arrow-icon
66
67
  :key="`pagination-${data.allPages}`"
67
68
  :total="data.allPages"
@@ -93,6 +94,10 @@ const props = defineProps<{
93
94
  data: PaginatedResponse<IVideoCard> | undefined
94
95
  status: string
95
96
  hideCategories?: boolean
97
+ /** Выдача без сортировки и фильтров (векторный поиск) — тулбар не на что навесить */
98
+ hideToolbar?: boolean
99
+ /** Выдача одной страницей (векторный поиск) — страниц у эндпоинта нет */
100
+ hidePagination?: boolean
96
101
  title?: string
97
102
  footerText?: string
98
103
  footerLinksLink?: string
@@ -0,0 +1,37 @@
1
+ import type { Language } from '../../runtime';
2
+ import { AsyncData } from '../../runtime';
3
+ import type { IVideoCard, PaginatedResponse } from '../../types';
4
+
5
+ /**
6
+ * Ролики для страниц поиска через векторную близость. У эндпоинта нет ни страниц, ни сортировки,
7
+ * ни фильтров — только limit, поэтому запрашивается один топ по релевантности, а query-параметры
8
+ * роута на запрос не влияют.
9
+ */
10
+ export const useFetchVectorVideosSearch = async (
11
+ apiMethod: (...args: any[]) => Promise<PaginatedResponse<IVideoCard>>,
12
+ { limit: limitOption }: { limit?: { mobile?: number, desktop?: number } } = {}
13
+ ) => {
14
+ const lang = useLang() as Language;
15
+ const mobile = !!useState<boolean>('isMobile').value;
16
+ const limit = (mobile ? limitOption?.mobile : limitOption?.desktop) ?? (mobile ? 12 : 48);
17
+ const slug = useSlug();
18
+ const key = AsyncData.VectorVideosSearch;
19
+ const stateKey = computed(() => `data-${key}-${slug.value}-${limit}-${lang}`);
20
+
21
+ const { data, status, error } = await useAsyncData<PaginatedResponse<IVideoCard>>(
22
+ stateKey.value,
23
+ () => {
24
+ if (!slug.value) return Promise.resolve(null as unknown as PaginatedResponse<IVideoCard>);
25
+
26
+ return useApiFetcher<PaginatedResponse<IVideoCard>>(
27
+ stateKey.value,
28
+ apiMethod,
29
+ limit,
30
+ lang,
31
+ slug.value
32
+ )();
33
+ }
34
+ );
35
+
36
+ return { data, status, error };
37
+ };
@@ -0,0 +1,54 @@
1
+ import type { IModelImageUrls } from '../types';
2
+ import { ModelImageSize } from '../runtime';
3
+
4
+ export interface ITestModelPortrait {
5
+ primaryImageUrl: string;
6
+ thumbUrl: string;
7
+ primaryImageUrls?: IModelImageUrls;
8
+ }
9
+
10
+ const PORTRAIT_COUNT = 25;
11
+
12
+ /**
13
+ * Тестовые портреты моделей из `public/img/models/`. Формат переключается query-параметром —
14
+ * для замера веса и сравнения качества на одной и той же странице:
15
+ *
16
+ * - `?img=png` — исходники без ладдера, как было до нарезки;
17
+ * - `?img=webp` — ладдер без avif-ветки, чтобы увидеть, сколько даёт именно avif;
18
+ * - без параметра — avif + webp.
19
+ *
20
+ * Читается один раз на монтировании страницы, поэтому переключение — перезагрузкой,
21
+ * а не навигацией по query. Для сравнения сети это и нужно: замер делается с чистого захода.
22
+ *
23
+ * Исходники разнокалиберные: portrait-1..16 — 640×960, portrait-17..25 — 1000×1200. В режиме
24
+ * `png` это видно, нарезка же у всех одинаковая, так что сравнивать с `png` надо с поправкой.
25
+ */
26
+ export function useTestModelImages() {
27
+ const format = useRoute().query['img'];
28
+
29
+ const portraits: ITestModelPortrait[] = Array.from({ length: PORTRAIT_COUNT }, (_, index) => {
30
+ const base = `/img/models/portrait-${index + 1}`;
31
+
32
+ if (format === 'png') {
33
+ return { primaryImageUrl: `${base}.png`, thumbUrl: `${base}.png`, primaryImageUrls: undefined };
34
+ }
35
+
36
+ const ladder = (extension: string) => ({
37
+ [ModelImageSize.Small]: `${base}-300.${extension}`,
38
+ [ModelImageSize.Medium]: `${base}-600.${extension}`,
39
+ });
40
+
41
+ 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
+ },
48
+ };
49
+ });
50
+
51
+ const portraitAt = (index: number) => portraits[index % PORTRAIT_COUNT]!;
52
+
53
+ return { portraits, portraitAt };
54
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.967",
4
+ "version": "0.0.969",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -47,10 +47,12 @@ const [
47
47
  useRouteQueryCategories(),
48
48
  ]);
49
49
 
50
- const testModelImages = Array.from({ length: 16 }, (_, index) => `/img/models/portrait-${index + 1}.png`);
50
+ const { portraits, portraitAt } = useTestModelImages();
51
51
 
52
52
  if (cardInfo.value) {
53
- cardInfo.value.img = testModelImages[Math.floor(Math.random() * testModelImages.length)];
53
+ const portrait = portraitAt(Math.floor(Math.random() * portraits.length));
54
+ cardInfo.value.img = portrait.primaryImageUrl;
55
+ cardInfo.value.imgUrls = portrait.primaryImageUrls;
54
56
  }
55
57
 
56
58
  const { t } = useI18n();
@@ -25,7 +25,7 @@
25
25
  </template>
26
26
 
27
27
  <script setup lang="ts">
28
- import type { IBreadcrumbItem } from '../../types';
28
+ import type { IBreadcrumbItem, IModelImageUrls } from '../../types';
29
29
  import { ModelsApiService } from '../../services/api/models.service';
30
30
  import { AsyncData } from '../../runtime';
31
31
  import { sortModels } from '../../lib';
@@ -34,17 +34,10 @@ const { t } = useI18n();
34
34
  const pageName = useRoute().name;
35
35
  const pagePureName = useGetPureRouteName();
36
36
 
37
- const testModelImages = Array.from({ length: 16 }, (_, index) => `/img/models/portrait-${index + 1}.png`);
37
+ const { portraitAt } = useTestModelImages();
38
38
 
39
- /** Портреты 3:4 — только под карточку модели, круглым аватарам топа они не подходят. */
40
- const testModelPortraits = Array.from({ length: 16 }, (_, index) => `/img/models/portrait-${index + 1}.png`);
41
-
42
- function applyTestImages<T extends { primaryImageUrl?: string; thumbUrl?: string }>(items: T[] | undefined, images: string[]): T[] | undefined {
43
- return items?.map((item, i) => ({
44
- ...item,
45
- primaryImageUrl: images[i % images.length],
46
- thumbUrl: images[i % images.length],
47
- }));
39
+ function applyTestImages<T extends { primaryImageUrl?: string; thumbUrl?: string; primaryImageUrls?: IModelImageUrls }>(items: T[] | undefined): T[] | undefined {
40
+ return items?.map((item, i) => ({ ...item, ...portraitAt(i) }));
48
41
  }
49
42
 
50
43
  const [
@@ -56,10 +49,10 @@ const [
56
49
  ]);
57
50
 
58
51
  if (topModels.value?.items) {
59
- topModels.value.items = applyTestImages(topModels.value.items, testModelImages)!;
52
+ topModels.value.items = applyTestImages(topModels.value.items)!;
60
53
  }
61
54
  if (models.value?.items) {
62
- models.value.items = applyTestImages(models.value.items, testModelPortraits)!;
55
+ models.value.items = applyTestImages(models.value.items)!;
63
56
  }
64
57
 
65
58
  const { meta, h1 } = useMeta({
@@ -125,6 +125,10 @@ const featureFlags = useRuntimeConfig().public.featureFlags;
125
125
 
126
126
  const slug = useSlug();
127
127
 
128
+ const fetchVideos = featureFlags.VectorSearchEnabled
129
+ ? useFetchVectorVideosSearch(VideosApiService.getVectorSearch)
130
+ : useFetchVideosSearchByNiche(VideosApiService.getSearchByNiche);
131
+
128
132
  const [
129
133
  { data: modelsData },
130
134
  { data: categoriesData },
@@ -133,7 +137,7 @@ const [
133
137
  ] = await Promise.all([
134
138
  useFetchModelsByPhrases(ModelsApiService.getModelsSearch),
135
139
  useFetchCategoriesByPhrase(CategoriesApiService.getCategoriesByPhrase),
136
- useFetchVideosSearchByNiche(VideosApiService.getSearchByNiche),
140
+ fetchVideos,
137
141
  useFetchRelatedSearch(SearchApiService.getPhrases),
138
142
  ]);
139
143
 
@@ -10,7 +10,9 @@
10
10
  :status="videosStatus"
11
11
  :title="h1"
12
12
  :data-models="null"
13
- :sort-items="sortItemsSearch"
13
+ :sort-items="vectorSearch ? undefined : sortItemsSearch"
14
+ :hide-toolbar="vectorSearch"
15
+ :hide-pagination="vectorSearch"
14
16
  hide-categories
15
17
  />
16
18
  <div
@@ -30,10 +32,14 @@ const slug = useSlug();
30
32
 
31
33
  const { t } = useI18n();
32
34
 
35
+ const vectorSearch = useRuntimeConfig().public.featureFlags.VectorSearchEnabled;
36
+
33
37
  const [
34
38
  { data: videosData, status: videosStatus },
35
39
  ] = await Promise.all([
36
- useFetchVideos(VideosApiService.getSearchByNicheFiltered(slug.value), { sortItems: sortItemsSearch, defaultOrder: '-_score' }),
40
+ vectorSearch
41
+ ? useFetchVectorVideosSearch(VideosApiService.getVectorSearch, { limit: { mobile: 24, desktop: 48 } })
42
+ : useFetchVideos(VideosApiService.getSearchByNicheFiltered(slug.value), { sortItems: sortItemsSearch, defaultOrder: '-_score' }),
37
43
  ]);
38
44
 
39
45
  useNoindexThinListing(() => videosData.value?.items?.length);
@@ -45,7 +51,7 @@ const { meta, h1 } = useMeta({
45
51
  t,
46
52
  pageKey: pagePureName,
47
53
  brandName: useAppConfig().brandName,
48
- sortOptions: sortItemsSearch,
54
+ sortOptions: vectorSearch ? [] : sortItemsSearch,
49
55
  text: metaTitle,
50
56
  });
51
57
 
@@ -13,6 +13,7 @@ export const AsyncData = {
13
13
  ModelsByFirstLetterChips: 'models-by-first-letter-chips',
14
14
  RelatedVideos: 'related-videos',
15
15
  VectorRelatedVideos: 'vector-related-videos',
16
+ VectorVideosSearch: 'vector-videos-search',
16
17
  RefererPhrases: 'referer-phrases-video',
17
18
  AsideVideos: 'aside-videos',
18
19
  TopModels: 'top-models',
@@ -0,0 +1,6 @@
1
+ export const ModelImageSize = {
2
+ Small: '300x400',
3
+ Medium: '600x800',
4
+ } as const;
5
+
6
+ export type ModelImageSize = typeof ModelImageSize[keyof typeof ModelImageSize];
package/runtime/index.ts CHANGED
@@ -8,7 +8,9 @@ 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';
11
12
  export * from './constants/webcams';
13
+ export * from './utils/build-srcset';
12
14
  export * from './utils/format-date';
13
15
  export * from './utils/format-time';
14
16
  export * from './utils/get-multiple-query';
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildSrcset } from '../build-srcset';
3
+
4
+ describe('buildSrcset', () => {
5
+ it('собирает дескрипторы ширины из ключей карты', () => {
6
+ expect(buildSrcset({
7
+ '300x400': 'https://img.com/a-300.webp',
8
+ '600x800': 'https://img.com/a-600.webp',
9
+ })).toBe('https://img.com/a-300.webp 300w, https://img.com/a-600.webp 600w');
10
+ });
11
+
12
+ it('отбрасывает пустые URL', () => {
13
+ expect(buildSrcset({
14
+ '300x400': '',
15
+ '600x800': 'https://img.com/a-600.webp',
16
+ })).toBe('https://img.com/a-600.webp 600w');
17
+ });
18
+
19
+ it('без карты или без пригодных записей → undefined', () => {
20
+ expect(buildSrcset()).toBeUndefined();
21
+ expect(buildSrcset({})).toBeUndefined();
22
+ expect(buildSrcset({ '300x400': '' })).toBeUndefined();
23
+ });
24
+ });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Собирает `srcset` из карты `<ширина>x<высота>` → URL. Пустые URL отбрасываются:
3
+ * кандидат с пустым `src` браузер берёт за адрес документа и тянет HTML вместо картинки.
4
+ */
5
+ export const buildSrcset = (urls?: Partial<Record<string, string>>): string | undefined => {
6
+ if (!urls) return undefined;
7
+
8
+ const entries = Object.entries(urls).filter(([, url]) => Boolean(url));
9
+ if (!entries.length) return undefined;
10
+
11
+ return entries
12
+ .map(([size, url]) => `${url} ${size.split('x')[0]}w`)
13
+ .join(', ');
14
+ };
@@ -26,6 +26,33 @@ describe('cleanModelCard', () => {
26
26
  expect(result.country).toEqual({ title: 'United States of America', name: 'us' });
27
27
  });
28
28
 
29
+ it('primary_image_urls → ладдер портрета, недостающий размер пустой строкой', () => {
30
+ const raw = makeRaw({
31
+ primary_image_urls: { webp: { '600x800': 'https://img.com/p-600.webp' } },
32
+ });
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',
49
+ });
50
+ });
51
+
52
+ it('без primary_image_urls → ладдера нет', () => {
53
+ expect(cleanModelCard(makeRaw() as any).primaryImageUrls).toBeUndefined();
54
+ });
55
+
29
56
  it('параметры: guy_type, ethnicity, age', () => {
30
57
  const result = cleanModelCard(makeRaw() as any);
31
58
  expect(result.parameters).toEqual([
@@ -1,7 +1,12 @@
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';
3
4
 
4
5
  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
+ };
5
10
  const firstValue = (card: IRawModelCard, name: string) => card.parameters?.find(item => item.name === name)?.values?.[0];
6
11
  const age = (card: IRawModelCard) => firstValue(card, 'age')?.title;
7
12
 
@@ -11,6 +16,10 @@ export const cleanModelCard = (card: IRawModelCard): IModelCard => ({
11
16
  videosCount: card.videos_count || 0,
12
17
  thumbUrl: card.video_thumb_urls?.webp?.[ThumbSize.Small] || '',
13
18
  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
23
  guid: card.guid || '',
15
24
  // Страна лежит в parameters под именем nationality_iso_codes: `name` — код ISO-2 (`us`),
16
25
  // он же имя файла флага, `title` — читаемое название. Одноимённое поле верхнего уровня
@@ -44,6 +44,32 @@ describe('convertModelToRoundCard', () => {
44
44
  expect(result.primaryImageUrl).toBe('');
45
45
  });
46
46
 
47
+ it('пробрасывает ладдер портрета', () => {
48
+ const model = {
49
+ title: 'Test',
50
+ primaryImageUrl: 'https://img.com/primary.jpg',
51
+ primaryImageUrls: { webp: { '300x400': 'https://img.com/a-300.webp' } },
52
+ thumbUrl: '',
53
+ videosCount: 0,
54
+ guid: 'm-5',
55
+ parameters: [],
56
+ };
57
+ const result = convertModelToRoundCard(model);
58
+ expect(result.primaryImageUrls).toEqual({ webp: { '300x400': 'https://img.com/a-300.webp' } });
59
+ });
60
+
61
+ it('без ладдера → undefined', () => {
62
+ const model = {
63
+ title: 'Test',
64
+ primaryImageUrl: 'url',
65
+ thumbUrl: '',
66
+ videosCount: 0,
67
+ guid: 'm-6',
68
+ parameters: [],
69
+ };
70
+ expect(convertModelToRoundCard(model).primaryImageUrls).toBeUndefined();
71
+ });
72
+
47
73
  it('slug с пробелами и заглавными', () => {
48
74
  const model = {
49
75
  title: 'Big Name Model',
@@ -6,6 +6,7 @@ export function convertModelToRoundCard(model: IModelCard): IRoundCard {
6
6
  return {
7
7
  title: model.title,
8
8
  primaryImageUrl: model.primaryImageUrl || model.thumbUrl || '',
9
+ primaryImageUrls: model.primaryImageUrls,
9
10
  videosCount: model.videosCount,
10
11
  link: `/models/${convertString().toSlug(model.title)}`,
11
12
  };
@@ -0,0 +1,30 @@
1
+ import type { IVideoCard, IRawVideoCard } from '../../../types';
2
+ import { cleanVideoCard, getUrlWithProxiedParams } from '../../../runtime';
3
+
4
+ /** Поиск роликов по векторной близости: POST /v1/videos-vectors/search, из параметров только limit. */
5
+ export default defineEventHandler(async (event) => {
6
+ const apiUrl = process.env.NUXT_PUBLIC_API_DOMAIN;
7
+
8
+ const url = getUrlWithProxiedParams(`${apiUrl}/v1/videos-vectors/search`, event);
9
+
10
+ const clientHeaders = event.node.req.headers as HeadersInit;
11
+
12
+ const body = await readBody(event);
13
+
14
+ const rawData = await apiFetch(url, {
15
+ method: 'POST',
16
+ headers: clientHeaders,
17
+ body: JSON.stringify(body),
18
+ });
19
+
20
+ const items: IRawVideoCard[] = rawData.data?.items || [];
21
+
22
+ const cleanedItems: IVideoCard[] = items.map(cleanVideoCard);
23
+
24
+ return {
25
+ currentPage: rawData.data?.current_page,
26
+ allPages: rawData.data?.all_pages,
27
+ total: rawData.data?.total,
28
+ items: cleanedItems,
29
+ };
30
+ });
@@ -85,6 +85,34 @@ export class VideosApiService {
85
85
  );
86
86
  };
87
87
  }
88
+ /** Векторный поиск роликов: без страниц, сортировки и фильтров — только limit. */
89
+ static async getVectorSearch(
90
+ limit: number,
91
+ lang: Language,
92
+ phrase: string,
93
+ niche = NicheHelper.getSiteDefaultNiche(),
94
+ ): Promise<PaginatedResponse<IVideoCard>> {
95
+ return await ApiHelper.fetch(
96
+ '/videos/vector-search',
97
+ {
98
+ method: 'POST',
99
+ headers: {
100
+ 'X-Niche': niche,
101
+ 'X-Language': toHeaderLanguage(lang),
102
+ 'X-Cache-Time': '259200',
103
+ 'Content-Type': 'application/json',
104
+ },
105
+ params: {
106
+ limit,
107
+ },
108
+ body: {
109
+ query: phrase,
110
+ }
111
+ },
112
+ useRuntimeConfig(),
113
+ );
114
+ }
115
+
88
116
  static async sendVideoAbuse(
89
117
  data: IReportForm,
90
118
  videoGuid: string,
@@ -68,21 +68,21 @@ function buildVideos(slug: string, offset: number, newCount: number): LibraryFol
68
68
 
69
69
  /** Порядок по числу новинок намеренно не совпадает с алфавитным — иначе обе сортировки дают одно и то же. */
70
70
  const MODELS = [
71
- { title: 'Dakota Lovell', image: 'portrait-1.png', country: { name: 'us', title: 'USA' }, age: 22, videosCount: 41, newCount: 3, guyType: 'Twink', bodyBuild: 'Slim', ethnicity: 'Caucasian', updatedAgo: 6 * HOUR, videoOffset: 3 },
72
- { title: 'Jax Thirio', image: 'portrait-2.png', country: { name: 'ca', title: 'Canada' }, age: 38, videosCount: 17, newCount: 2, guyType: 'Daddy', bodyBuild: 'Muscular', ethnicity: 'Latino', updatedAgo: 2 * DAY, videoOffset: 9 },
73
- { title: 'Austin Young', image: 'portrait-3.png', country: { name: 'us', title: 'USA' }, age: 24, videosCount: 75, newCount: 1, guyType: 'Twink', bodyBuild: 'Athletic', ethnicity: 'Caucasian', updatedAgo: 9 * DAY, videoOffset: 0 },
74
- { title: 'Devin Franco', image: 'portrait-4.png', country: { name: 'us', title: 'USA' }, age: 33, videosCount: 24, newCount: 0, guyType: 'Jock', bodyBuild: 'Muscular', ethnicity: 'Black', updatedAgo: 21 * DAY, videoOffset: 6 },
75
- { title: 'Dakota Lovell', image: 'portrait-1.png', country: { name: 'us', title: 'USA' }, age: 22, videosCount: 41, newCount: 3, guyType: 'Twink', bodyBuild: 'Slim', ethnicity: 'Caucasian', updatedAgo: 6 * HOUR, videoOffset: 3 },
76
- { title: 'Jax Thirio', image: 'portrait-2.png', country: { name: 'ca', title: 'Canada' }, age: 38, videosCount: 17, newCount: 2, guyType: 'Daddy', bodyBuild: 'Muscular', ethnicity: 'Latino', updatedAgo: 2 * DAY, videoOffset: 9 },
77
- { title: 'Austin Young', image: 'portrait-3.png', country: { name: 'us', title: 'USA' }, age: 24, videosCount: 75, newCount: 1, guyType: 'Twink', bodyBuild: 'Athletic', ethnicity: 'Caucasian', updatedAgo: 9 * DAY, videoOffset: 0 },
78
- { title: 'Devin Franco', image: 'portrait-4.png', country: { name: 'us', title: 'USA' }, age: 33, videosCount: 24, newCount: 0, guyType: 'Jock', bodyBuild: 'Muscular', ethnicity: 'Black', updatedAgo: 21 * DAY, videoOffset: 6 },
79
- { title: 'Dakota Lovell', image: 'portrait-1.png', country: { name: 'us', title: 'USA' }, age: 22, videosCount: 41, newCount: 3, guyType: 'Twink', bodyBuild: 'Slim', ethnicity: 'Caucasian', updatedAgo: 6 * HOUR, videoOffset: 3 },
80
- { title: 'Dakota Lovell', image: 'portrait-1.png', country: { name: 'us', title: 'USA' }, age: 22, videosCount: 41, newCount: 3, guyType: 'Twink', bodyBuild: 'Slim', ethnicity: 'Caucasian', updatedAgo: 6 * HOUR, videoOffset: 3 },
81
- { title: 'Dakota Lovell', image: 'portrait-1.png', country: { name: 'us', title: 'USA' }, age: 22, videosCount: 41, newCount: 3, guyType: 'Twink', bodyBuild: 'Slim', ethnicity: 'Caucasian', updatedAgo: 6 * HOUR, videoOffset: 3 },
82
- { title: 'Dakota Lovell', image: 'portrait-1.png', country: { name: 'us', title: 'USA' }, age: 22, videosCount: 41, newCount: 3, guyType: 'Twink', bodyBuild: 'Slim', ethnicity: 'Caucasian', updatedAgo: 6 * HOUR, videoOffset: 3 },
83
- { title: 'Jax Thirio', image: 'portrait-2.png', country: { name: 'ca', title: 'Canada' }, age: 38, videosCount: 17, newCount: 2, guyType: 'Daddy', bodyBuild: 'Muscular', ethnicity: 'Latino', updatedAgo: 2 * DAY, videoOffset: 9 },
84
- { title: 'Austin Young', image: 'portrait-3.png', country: { name: 'us', title: 'USA' }, age: 24, videosCount: 75, newCount: 1, guyType: 'Twink', bodyBuild: 'Athletic', ethnicity: 'Caucasian', updatedAgo: 9 * DAY, videoOffset: 0 },
85
- { title: 'Devin Franco', image: 'portrait-4.png', country: { name: 'us', title: 'USA' }, age: 33, videosCount: 24, newCount: 0, guyType: 'Jock', bodyBuild: 'Muscular', ethnicity: 'Black', updatedAgo: 21 * DAY, videoOffset: 6 },
71
+ { title: 'Dakota Lovell', image: 'portrait-1-300.webp', country: { name: 'us', title: 'USA' }, age: 22, videosCount: 41, newCount: 3, guyType: 'Twink', bodyBuild: 'Slim', ethnicity: 'Caucasian', updatedAgo: 6 * HOUR, videoOffset: 3 },
72
+ { title: 'Jax Thirio', image: 'portrait-2-300.webp', country: { name: 'ca', title: 'Canada' }, age: 38, videosCount: 17, newCount: 2, guyType: 'Daddy', bodyBuild: 'Muscular', ethnicity: 'Latino', updatedAgo: 2 * DAY, videoOffset: 9 },
73
+ { title: 'Austin Young', image: 'portrait-3-300.webp', country: { name: 'us', title: 'USA' }, age: 24, videosCount: 75, newCount: 1, guyType: 'Twink', bodyBuild: 'Athletic', ethnicity: 'Caucasian', updatedAgo: 9 * DAY, videoOffset: 0 },
74
+ { title: 'Devin Franco', image: 'portrait-4-300.webp', country: { name: 'us', title: 'USA' }, age: 33, videosCount: 24, newCount: 0, guyType: 'Jock', bodyBuild: 'Muscular', ethnicity: 'Black', updatedAgo: 21 * DAY, videoOffset: 6 },
75
+ { title: 'Dakota Lovell', image: 'portrait-1-300.webp', country: { name: 'us', title: 'USA' }, age: 22, videosCount: 41, newCount: 3, guyType: 'Twink', bodyBuild: 'Slim', ethnicity: 'Caucasian', updatedAgo: 6 * HOUR, videoOffset: 3 },
76
+ { title: 'Jax Thirio', image: 'portrait-2-300.webp', country: { name: 'ca', title: 'Canada' }, age: 38, videosCount: 17, newCount: 2, guyType: 'Daddy', bodyBuild: 'Muscular', ethnicity: 'Latino', updatedAgo: 2 * DAY, videoOffset: 9 },
77
+ { title: 'Austin Young', image: 'portrait-3-300.webp', country: { name: 'us', title: 'USA' }, age: 24, videosCount: 75, newCount: 1, guyType: 'Twink', bodyBuild: 'Athletic', ethnicity: 'Caucasian', updatedAgo: 9 * DAY, videoOffset: 0 },
78
+ { title: 'Devin Franco', image: 'portrait-4-300.webp', country: { name: 'us', title: 'USA' }, age: 33, videosCount: 24, newCount: 0, guyType: 'Jock', bodyBuild: 'Muscular', ethnicity: 'Black', updatedAgo: 21 * DAY, videoOffset: 6 },
79
+ { title: 'Dakota Lovell', image: 'portrait-1-300.webp', country: { name: 'us', title: 'USA' }, age: 22, videosCount: 41, newCount: 3, guyType: 'Twink', bodyBuild: 'Slim', ethnicity: 'Caucasian', updatedAgo: 6 * HOUR, videoOffset: 3 },
80
+ { title: 'Dakota Lovell', image: 'portrait-1-300.webp', country: { name: 'us', title: 'USA' }, age: 22, videosCount: 41, newCount: 3, guyType: 'Twink', bodyBuild: 'Slim', ethnicity: 'Caucasian', updatedAgo: 6 * HOUR, videoOffset: 3 },
81
+ { title: 'Dakota Lovell', image: 'portrait-1-300.webp', country: { name: 'us', title: 'USA' }, age: 22, videosCount: 41, newCount: 3, guyType: 'Twink', bodyBuild: 'Slim', ethnicity: 'Caucasian', updatedAgo: 6 * HOUR, videoOffset: 3 },
82
+ { title: 'Dakota Lovell', image: 'portrait-1-300.webp', country: { name: 'us', title: 'USA' }, age: 22, videosCount: 41, newCount: 3, guyType: 'Twink', bodyBuild: 'Slim', ethnicity: 'Caucasian', updatedAgo: 6 * HOUR, videoOffset: 3 },
83
+ { title: 'Jax Thirio', image: 'portrait-2-300.webp', country: { name: 'ca', title: 'Canada' }, age: 38, videosCount: 17, newCount: 2, guyType: 'Daddy', bodyBuild: 'Muscular', ethnicity: 'Latino', updatedAgo: 2 * DAY, videoOffset: 9 },
84
+ { title: 'Austin Young', image: 'portrait-3-300.webp', country: { name: 'us', title: 'USA' }, age: 24, videosCount: 75, newCount: 1, guyType: 'Twink', bodyBuild: 'Athletic', ethnicity: 'Caucasian', updatedAgo: 9 * DAY, videoOffset: 0 },
85
+ { title: 'Devin Franco', image: 'portrait-4-300.webp', country: { name: 'us', title: 'USA' }, age: 33, videosCount: 24, newCount: 0, guyType: 'Jock', bodyBuild: 'Muscular', ethnicity: 'Black', updatedAgo: 21 * DAY, videoOffset: 6 },
86
86
  ];
87
87
 
88
88
  const CHANNELS = [
@@ -1,5 +1,6 @@
1
1
  import type { IParameterModel } from './parameter-model';
2
2
  import type { IChipsItem } from './chips-item';
3
+ import type { IModelImageUrls } from './model-image-urls';
3
4
  import type { IPageSeo } from './page-seo';
4
5
 
5
6
  export interface ICardInfo {
@@ -7,6 +8,8 @@ export interface ICardInfo {
7
8
  description: string;
8
9
  videosCount: number;
9
10
  img?: string;
11
+ /** Ладдер портрета под srcset. Нет — рендерится один `img`. */
12
+ imgUrls?: IModelImageUrls;
10
13
  guid?: string;
11
14
  fullDescription?: string
12
15
  miniCardTitle?: string
package/types/index.d.ts CHANGED
@@ -2,7 +2,9 @@ export * from './site.d.ts';
2
2
  export * from './video-data.d.ts';
3
3
  export * from './video-card.d.ts';
4
4
  export * from './thumbs-urls.d.ts';
5
+ export * from './model-image-urls.d.ts';
5
6
  export * from './raw/raw-thumbs-urls.d.ts';
7
+ export * from './raw/raw-model-image-urls.d.ts';
6
8
  export * from './raw/raw-category-info.d.ts';
7
9
  export * from './raw/raw-category-card.d.ts';
8
10
  export * from './raw/raw-channel-card.d.ts';
@@ -1,8 +1,12 @@
1
+ import type { IModelImageUrls } from './model-image-urls';
2
+
1
3
  export interface IModelCard {
2
4
  title: string;
3
5
  videosCount: number;
4
6
  thumbUrl: string;
5
7
  primaryImageUrl?: string;
8
+ /** Ладдер портрета под srcset. Нет — карточка рендерит один `primaryImageUrl`. */
9
+ primaryImageUrls?: IModelImageUrls;
6
10
  guid: string;
7
11
  age?: string;
8
12
  country?: {
@@ -0,0 +1,7 @@
1
+ import type { ModelImageSize } from '../runtime/constants/model-image-size';
2
+
3
+ /** Очищенный портрет модели, ключ `<ширина>x<высота>`. `avif` необязателен — webp несущий. */
4
+ export interface IModelImageUrls {
5
+ avif?: Partial<Record<ModelImageSize, string>>;
6
+ webp: Partial<Record<ModelImageSize, string>>;
7
+ }
@@ -1,4 +1,5 @@
1
1
  import type { IRawThumbUrls } from './raw-thumbs-urls';
2
+ import type { IRawModelImageUrls } from './raw-model-image-urls';
2
3
  import type { IModelParameter } from '~/types';
3
4
 
4
5
  export interface IRawModelCard {
@@ -8,6 +9,7 @@ export interface IRawModelCard {
8
9
  guid: string;
9
10
  likes: number;
10
11
  primary_image_url: string;
12
+ primary_image_urls?: IRawModelImageUrls;
11
13
  number_of_videos_in_recent_days: number;
12
14
  orientation: string;
13
15
 
@@ -0,0 +1,8 @@
1
+ import type { ModelImageSize } from '../../runtime/constants/model-image-size';
2
+
3
+ /** Raw портрет модели из API — зеркало `video_thumb_urls`, но с портретными размерами */
4
+ export interface IRawModelImageUrls {
5
+ jpeg?: Partial<Record<ModelImageSize, string>>;
6
+ webp?: Partial<Record<ModelImageSize, string>>;
7
+ avif?: Partial<Record<ModelImageSize, string>>;
8
+ }
@@ -1,6 +1,10 @@
1
+ import type { IModelImageUrls } from './model-image-urls';
2
+
1
3
  export interface IRoundCard {
2
4
  title: string
3
5
  primaryImageUrl: string
6
+ /** Ладдер портрета под srcset. Нет — карточка рендерит один `primaryImageUrl`. */
7
+ primaryImageUrls?: IModelImageUrls
4
8
  videosCount: number
5
9
  link: string
6
10
  /** Новинки с прошлого захода — бейдж на аватаре (страница подписок) */
@@ -1,6 +1,10 @@
1
+ import type { IModelImageUrls } from './model-image-urls';
2
+
1
3
  export interface ISearchTopModelsType {
2
4
  videosCount?: number
3
5
  name: string
4
6
  title: string
5
7
  primaryImageUrl?: string
8
+ /** Ладдер портрета под srcset. Нет — рендерится один `primaryImageUrl`. */
9
+ primaryImageUrls?: IModelImageUrls
6
10
  }