itube-specs 0.0.966 → 0.0.968

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 (36) 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 +49 -24
  6. package/components/search/item.vue +2 -1
  7. package/components/ui/img.vue +58 -19
  8. package/components/view/video.vue +2 -2
  9. package/components/view/videos.vue +6 -1
  10. package/composables/fetch/use-fetch-vector-videos-search.ts +37 -0
  11. package/composables/use-test-model-images.ts +54 -0
  12. package/package.json +1 -1
  13. package/pages/models/[slug]/index.vue +4 -2
  14. package/pages/models/index.vue +6 -13
  15. package/pages/search/[slug]/index.vue +5 -1
  16. package/pages/search/[slug]/videos.vue +9 -3
  17. package/runtime/constants/async-data.ts +1 -0
  18. package/runtime/constants/model-image-size.ts +6 -0
  19. package/runtime/index.ts +2 -0
  20. package/runtime/utils/__tests__/build-srcset.test.ts +24 -0
  21. package/runtime/utils/build-srcset.ts +14 -0
  22. package/runtime/utils/cleaners/__tests__/clean-model-card.test.ts +27 -0
  23. package/runtime/utils/cleaners/clean-model-card.ts +9 -0
  24. package/runtime/utils/converters/__tests__/convert-model-to-round-card.test.ts +26 -0
  25. package/runtime/utils/converters/convert-model-to-round-card.ts +1 -0
  26. package/server/api/videos/vector-search.post.ts +30 -0
  27. package/services/api/videos.service.ts +28 -0
  28. package/stubs/library-follows.ts +15 -15
  29. package/types/card-info.d.ts +3 -0
  30. package/types/index.d.ts +2 -0
  31. package/types/model-card.d.ts +4 -0
  32. package/types/model-image-urls.d.ts +7 -0
  33. package/types/raw/raw-model-card.d.ts +2 -0
  34. package/types/raw/raw-model-image-urls.d.ts +8 -0
  35. package/types/round-card.d.ts +4 -0
  36. 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,
@@ -5,7 +5,10 @@
5
5
  макете её нет, но формат живёт именно в этой колонке.
6
6
 
7
7
  Если моделей у ролика нет (и рекламы тоже), колонка не пустует: показываем пару
8
- рекомендаций, как было до макета. -->
8
+ рекомендаций, как было до макета.
9
+
10
+ Высота колонки ограничена высотой плеера, лишнее уходит в прокрутку внутри неё —
11
+ см. комментарий к стилям. -->
9
12
  <template>
10
13
  <div
11
14
  :class="[
@@ -13,26 +16,28 @@
13
16
  ]"
14
17
  class="player-aside-videos _from-sd"
15
18
  >
16
- <PlayerAsideModels
17
- v-for="item in models || []"
18
- :key="`aside-model-${item.slug}`"
19
- :title="item.title"
20
- :slug="item.slug"
21
- :info="item.info"
22
- :videos="item.videos?.items"
23
- class="player-aside-videos__model"
24
- />
19
+ <div class="player-aside-videos__inner">
20
+ <PlayerAsideModels
21
+ v-for="item in models || []"
22
+ :key="`aside-model-${item.slug}`"
23
+ :title="item.title"
24
+ :slug="item.slug"
25
+ :info="item.info"
26
+ :videos="item.videos?.items"
27
+ class="player-aside-videos__model"
28
+ />
25
29
 
26
- <AdvNtv v-if="featureFlags.AdsNTVEnabled" />
30
+ <AdvNtv v-if="featureFlags.AdsNTVEnabled" />
27
31
 
28
- <template v-if="!models?.length && !featureFlags.AdsNTVEnabled">
29
- <CardVideo
30
- v-for="item in asideVideos?.items?.slice(0, 2)"
31
- :key="`aside-video-${item.guid}`"
32
- :card="item"
33
- mini
34
- />
35
- </template>
32
+ <template v-if="!models?.length && !featureFlags.AdsNTVEnabled">
33
+ <CardVideo
34
+ v-for="item in asideVideos?.items?.slice(0, 2)"
35
+ :key="`aside-video-${item.guid}`"
36
+ :card="item"
37
+ mini
38
+ />
39
+ </template>
40
+ </div>
36
41
  </div>
37
42
  </template>
38
43
 
@@ -56,16 +61,36 @@ defineProps<{
56
61
  </script>
57
62
 
58
63
  <style lang="scss">
64
+ /**
65
+ * Высоту колонке задаёт плеер, а не её содержимое: блоков моделей может быть
66
+ * больше, чем помещается рядом с роликом, и тогда колонка растягивала строку
67
+ * сетки — плеер с описанием уезжали вверх от остальной страницы. Поэтому сама
68
+ * колонка держит только рамку (её собственная высота для сетки — ноль), а всё
69
+ * содержимое лежит абсолютным слоем поверх и прокручивается внутри. Скроллбар
70
+ * общий, из base/_global.scss.
71
+ */
59
72
  .player-aside-videos {
60
73
  @include from-br(sd) {
61
- display: grid;
62
- gap: 32px;
63
- grid-template-columns: 1fr;
74
+ position: relative;
64
75
  width: 288px;
65
- height: 100%;
66
76
  grid-column: 2;
67
77
  grid-row: 1;
68
- align-content: start;
78
+ }
79
+ }
80
+
81
+ .player-aside-videos__inner {
82
+ display: grid;
83
+ gap: 32px;
84
+ grid-template-columns: 1fr;
85
+ align-content: start;
86
+
87
+ @include from-br(sd) {
88
+ position: absolute;
89
+ inset: 0;
90
+ overflow-y: auto;
91
+
92
+ // полоса прокрутки не должна наезжать на карточки
93
+ padding-right: 4px;
69
94
  }
70
95
  }
71
96
  </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
  />
@@ -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.966",
4
+ "version": "0.0.968",
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
  }