itube-specs 0.0.985 → 0.0.988

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 (35) hide show
  1. package/components/card/model-mini.vue +8 -6
  2. package/components/card/model.vue +7 -7
  3. package/components/card/round.vue +10 -5
  4. package/components/card/video/index.vue +21 -77
  5. package/components/info/main.vue +12 -7
  6. package/components/search/item.vue +5 -9
  7. package/components/search/result.vue +2 -0
  8. package/components/section/swiper-cards.vue +3 -0
  9. package/components/ui/chips.vue +2 -1
  10. package/components/view/model-videos.vue +2 -1
  11. package/components/view/models.vue +2 -1
  12. package/components/view/video.vue +5 -1
  13. package/components/view/videos.vue +2 -1
  14. package/package.json +1 -1
  15. package/pages/categories/[slug].vue +2 -1
  16. package/pages/channels/[channel].vue +2 -1
  17. package/pages/following.vue +2 -1
  18. package/pages/search/[slug]/index.vue +2 -1
  19. package/runtime/index.ts +2 -1
  20. package/runtime/utils/__tests__/video-data-add-model-portraits.test.ts +47 -0
  21. package/runtime/utils/cleaners/__tests__/clean-suggest-models.test.ts +40 -0
  22. package/runtime/utils/cleaners/__tests__/clean-video-data.test.ts +46 -1
  23. package/runtime/utils/cleaners/clean-suggest-models.ts +23 -0
  24. package/runtime/utils/cleaners/clean-video-data.ts +19 -2
  25. package/runtime/utils/video-data-add-model-portraits.ts +27 -0
  26. package/server/api/search/multi-suggest.get.ts +10 -4
  27. package/server/api/videos/get-video-by-id/[id].get.ts +6 -2
  28. package/server/api/videos/get-video-by-url/[url].get.ts +6 -2
  29. package/types/chips-item.d.ts +2 -0
  30. package/types/multi-suggest.d.ts +20 -2
  31. package/types/raw/raw-video-data.d.ts +3 -0
  32. package/types/search-top-models.d.ts +3 -6
  33. package/types/video-data.d.ts +3 -0
  34. package/runtime/utils/__tests__/video-data-add-model-icon.test.ts +0 -32
  35. package/runtime/utils/video-data-add-model-icon.ts +0 -25
@@ -52,16 +52,18 @@ const props = defineProps<{
52
52
  card: IModelCard
53
53
  }>();
54
54
 
55
- const fallbackImage = '/img/model-placeholder.webp';
55
+ const fallbackImage = '/img/model-placeholder-square.webp';
56
+ const fallbackAvifImage = '/img/model-placeholder-square.avif';
56
57
  const imageError = ref(false);
57
58
 
58
59
  /** Аватар 48px круглый — нужен квадратный кроп, вертикальный портрет сюда не годится. */
59
- const currentImage = computed(() => {
60
- if (imageError.value) return fallbackImage;
61
- return props.card.primaryImageSmallUrl || fallbackImage;
62
- });
60
+ const isFallback = computed(() => imageError.value || !props.card.primaryImageSmallUrl);
63
61
 
64
- const currentAvifSrc = computed(() => (imageError.value ? undefined : props.card.primaryImageSmallAvifUrl));
62
+ const currentImage = computed(() => (isFallback.value ? fallbackImage : props.card.primaryImageSmallUrl));
63
+
64
+ const currentAvifSrc = computed(() => (
65
+ isFallback.value ? fallbackAvifImage : props.card.primaryImageSmallAvifUrl
66
+ ));
65
67
  </script>
66
68
 
67
69
  <style lang="scss">
@@ -98,16 +98,16 @@ const { t } = useI18n();
98
98
  */
99
99
  const SIZES = 'auto, (max-width: 449px) 50vw, (max-width: 1385px) 215px, 250px';
100
100
 
101
- const fallbackImage = '/img/model-placeholder.webp';
101
+ const fallbackImage = '/img/model-placeholder-portrait.webp';
102
+ const fallbackAvifImage = '/img/model-placeholder-portrait.avif';
102
103
  const imageError = ref(false);
103
104
 
104
- const currentImage = computed(() => {
105
- if (imageError.value) return fallbackImage;
106
- return props.card.primaryImageUrl || fallbackImage;
107
- });
105
+ const isFallback = computed(() => imageError.value || !props.card.primaryImageUrl);
106
+
107
+ const currentImage = computed(() => (isFallback.value ? fallbackImage : props.card.primaryImageUrl));
108
108
 
109
- /** На заглушке ладдер сбрасываем — иначе браузер продолжит тянуть битые кандидаты. */
110
- const currentAvifSrc = computed(() => (imageError.value ? undefined : props.card.primaryImageAvifUrl));
109
+ /** На заглушке подменяем и avif-ветку — иначе браузер продолжит тянуть битый кандидат. */
110
+ const currentAvifSrc = computed(() => (isFallback.value ? fallbackAvifImage : props.card.primaryImageAvifUrl));
111
111
 
112
112
  function onImageError() {
113
113
  imageError.value = true;
@@ -49,6 +49,8 @@ const props = withDefaults(defineProps<{
49
49
  loading?: 'eager' | 'lazy'
50
50
  priority?: boolean
51
51
  placeholderAvatar?: string
52
+ /** Та же заглушка в avif. У канальной (svg) пары нет — тогда проп не передают. */
53
+ placeholderAvatarAvif?: string
52
54
  /** Только аватар и название — без счётчика роликов (страница подписок) */
53
55
  withoutCount?: boolean
54
56
  }>(), {
@@ -64,12 +66,15 @@ const SIZES = `auto, (max-width: ${CSS_BREAKPOINTS.sm - 1}px) 80px, 96px`;
64
66
  const fallbackImage = props.placeholderAvatar;
65
67
  const imageError = ref(false);
66
68
 
67
- const currentImage = computed(() => {
68
- if (imageError.value) return fallbackImage || '';
69
- return props.card.primaryImageUrl || fallbackImage || '';
70
- });
69
+ const isFallback = computed(() => imageError.value || !props.card.primaryImageUrl);
70
+
71
+ const currentImage = computed(() => (
72
+ (isFallback.value ? fallbackImage : props.card.primaryImageUrl) || ''
73
+ ));
71
74
 
72
- const currentAvifSrc = computed(() => (imageError.value ? undefined : props.card.primaryImageAvifUrl));
75
+ const currentAvifSrc = computed(() => (
76
+ isFallback.value ? props.placeholderAvatarAvif : props.card.primaryImageAvifUrl
77
+ ));
73
78
 
74
79
  function onImageError() {
75
80
  imageError.value = true;
@@ -5,7 +5,6 @@
5
5
  {'--mini': mini},
6
6
  {'--compact': compact},
7
7
  {'--avatar': card.channelAvatar},
8
- {'--no-channel': isChannelPage},
9
8
  {'--progress': hasProgress},
10
9
  {'--favorite': isFavoriteCard},
11
10
  {'--tags-open': isTagsOpen},
@@ -95,30 +94,10 @@
95
94
  class="card-video__title _truncate"
96
95
  >{{ card.title }}
97
96
  </NuxtLink>
98
- <component
99
- v-if="!mini && !compact && !history && card.channelName"
100
- :is="component"
101
- :to="channelLink"
102
- class="card-video__channel-wrapper"
103
- >
104
- <span
105
- :data-nosnippet="!card.channelName || null"
106
- class="card-video__channel-title"
107
- >
108
- <template v-if="card.channelName">
109
- {{ card.channelName }}
110
- </template>
111
- </span>
112
- </component>
113
97
  <p
114
98
  v-if="history"
115
99
  class="card-video__meta"
116
100
  >
117
- <NuxtLink
118
- v-if="card.channelName"
119
- :to="channelLink"
120
- class="card-video__channel-title"
121
- >{{ card.channelName }}</NuxtLink>
122
101
  <span
123
102
  :class="{'--remaining': remaining}"
124
103
  class="card-video__watched"
@@ -189,9 +168,6 @@ const component = computed(() => props.card.channelName ? NuxtLinkComponent : 'd
189
168
 
190
169
  const { generateLink } = useGenerateLink();
191
170
 
192
- const pageName = useGetPureRouteName();
193
- const isChannelPage = computed(() => pageName === 'channels-channel');
194
-
195
171
  const previewId = useId();
196
172
  const { activePreviewId, startPreviewByHover, stopPreviewByHover, startPreviewByTouch } = useVideoPreview();
197
173
 
@@ -383,7 +359,6 @@ $dropdown-column: 18px;
383
359
  grid-column: 2;
384
360
  }
385
361
 
386
- .--avatar .card-video__channel-wrapper,
387
362
  .--avatar .card-video__meta {
388
363
  grid-column: 2;
389
364
  }
@@ -398,25 +373,26 @@ $dropdown-column: 18px;
398
373
  grid-column: 2 / -1;
399
374
  }
400
375
 
376
+ /* Кружок канала — только на десктопе: на мобиле карточка отдаёт всю ширину заголовку */
401
377
  @include to-br(sm) {
402
- .--no-channel .card-video__logo-wrapper,
403
- .--no-channel .card-video__channel-wrapper {
378
+ .card-video .card-video__logo-wrapper {
404
379
  display: none;
405
380
  }
406
381
 
407
- .--no-channel .card-video__wrapper {
382
+ .card-video .card-video__wrapper {
408
383
  grid-template-columns: minmax(0, 1fr) $dropdown-column;
409
384
  }
410
385
 
411
- .--no-channel .card-video__title {
386
+ .card-video .card-video__title,
387
+ .card-video .card-video__meta {
412
388
  grid-column: 1;
413
389
  }
414
390
 
415
- .--no-channel .card-video__tags {
391
+ .card-video .card-video__tags {
416
392
  grid-column: 1 / -1;
417
393
  }
418
394
 
419
- .--no-channel .card-video__dropdown {
395
+ .card-video .card-video__dropdown {
420
396
  grid-column: 2;
421
397
  grid-row: 1;
422
398
  }
@@ -426,7 +402,7 @@ $dropdown-column: 18px;
426
402
  grid-template-columns: minmax(0, 1fr) $dropdown-column;
427
403
  }
428
404
 
429
- /* Без строки канала и тегов вторая строка грида не нужна — иначе аватар и «троеточие» растягиваются. */
405
+ /* Без тегов вторая строка грида не нужна — иначе кружок и «троеточие» растягиваются. */
430
406
  .--compact .card-video__logo-wrapper,
431
407
  .--compact .card-video__dropdown {
432
408
  grid-row: 1;
@@ -455,23 +431,7 @@ $dropdown-column: 18px;
455
431
  }
456
432
  }
457
433
 
458
- .card-video__channel-wrapper {
459
- @include font-xs;
460
-
461
- font-weight: 400;
462
- display: grid;
463
- grid-row: 2;
464
- grid-template-columns: auto 1fr;
465
- align-items: center;
466
- column-gap: 4px;
467
- margin-top: 6px;
468
-
469
- @include to-cq($layout-breakpoint) {
470
- grid-column: 1;
471
- }
472
- }
473
-
474
- /* Мета-строка истории: «канал · когда смотрели / сколько осталось» на месте строки канала */
434
+ /* Мета-строка истории: когда смотрели / сколько осталось */
475
435
  .card-video__meta {
476
436
  @include font-xs;
477
437
 
@@ -488,19 +448,6 @@ $dropdown-column: 18px;
488
448
  }
489
449
  }
490
450
 
491
- /* На узкой карточке (две колонки на телефоне) «канал · время» в строку не встают, а нешрикуемое
492
- время задаёт колонке min-content и разносит грид — оставляем только время просмотра */
493
- @include to-cq($layout-breakpoint) {
494
- .card-video__meta .card-video__channel-title {
495
- display: none;
496
- }
497
- }
498
-
499
- .card-video__meta .card-video__channel-title + .card-video__watched::before {
500
- content: '·';
501
- margin-inline: 6px;
502
- }
503
-
504
451
  .card-video__watched {
505
452
  display: flex;
506
453
  align-items: center;
@@ -519,29 +466,24 @@ $dropdown-column: 18px;
519
466
  border-radius: 50%;
520
467
  }
521
468
 
469
+ /* Кружок раскидан на обе строки: его 36px набираются заголовком плюс плашками, иначе под
470
+ однострочным заголовком остаётся пустой хвост высотой в остаток кружка */
522
471
  .card-video__logo-wrapper {
523
472
  grid-row: 1/3;
524
473
  grid-column: 1;
525
474
  }
526
475
 
527
- .card-video__logo-wrapper[href],
528
- .card-video__channel-wrapper[href] {
476
+ /* У видео без моделей плашек нет вовсе — второй строки тоже, кружку спан не по чему набирать */
477
+ .card-video__wrapper:not(:has(.card-video__tags, .card-video__meta)) .card-video__logo-wrapper {
478
+ grid-row: 1;
479
+ }
480
+
481
+ .card-video__logo-wrapper[href] {
529
482
  @include hover(opacity) {
530
483
  opacity: 0.9;
531
484
  }
532
485
  }
533
486
 
534
- .card-video__channel-title {
535
- @include font-xs;
536
-
537
- font-weight: 400;
538
- color: $video-card-secondary;
539
- overflow: hidden;
540
- white-space: nowrap;
541
- text-overflow: ellipsis;
542
- max-width: 100%;
543
- }
544
-
545
487
  .card-video.--mini {
546
488
  row-gap: 8px;
547
489
  }
@@ -561,13 +503,15 @@ $dropdown-column: 18px;
561
503
  z-index: $z-index-dropdown;
562
504
  }
563
505
 
564
- /* Ряд плашек лежит ниже «троеточия» — колонка под меню в этом ряду свободна, отдаём её плашкам */
506
+ /* Ряд плашек лежит ниже «троеточия» — колонка под меню в этом ряду свободна, отдаём её плашкам.
507
+ Строка задана явно: на авторазмещении плашки уезжали в третью строку, оставляя пустую вторую */
565
508
  .card-video__tags {
509
+ grid-row: 2;
566
510
  grid-column: 1 / -1;
567
511
 
568
512
  @include from-cq($layout-breakpoint) {
569
513
  grid-column: 2 / -1;
570
- margin-top: 10px;
514
+ margin-top: 6px;
571
515
  }
572
516
  }
573
517
 
@@ -204,20 +204,25 @@ function onFollowClick() {
204
204
  }
205
205
 
206
206
  const placeholders: Record<string, string> = {
207
- model: '/img/model-placeholder.webp',
207
+ model: '/img/model-placeholder-square.webp',
208
208
  channel: '/img/logo-placeholder.svg',
209
209
  };
210
- const placeholder = computed(() => placeholders[ props.type || 'channel' ]);
210
+ /** У канала заглушка — svg, avif-пары у неё нет. */
211
+ const avifPlaceholders: Record<string, string | undefined> = {
212
+ model: '/img/model-placeholder-square.avif',
213
+ };
214
+ const placeholderType = computed(() => props.type || 'channel');
215
+ const placeholder = computed(() => placeholders[ placeholderType.value ]);
211
216
  const imageError = ref(false);
212
- const currentImage = computed(() => {
213
- if (imageError.value) return placeholder.value;
214
- return props.card.img || placeholder.value;
215
- });
217
+ const isFallback = computed(() => imageError.value || !props.card.img);
218
+ const currentImage = computed(() => (isFallback.value ? placeholder.value : props.card.img));
216
219
 
217
220
  /** `.info-main__img` — 80px до sm, 96px дальше. `auto` впереди — см. `UiImg`. */
218
221
  const IMG_SIZES = `auto, (max-width: ${CSS_BREAKPOINTS.sm - 1}px) 80px, 96px`;
219
222
 
220
- const currentAvifSrc = computed(() => (imageError.value ? undefined : props.card.imgAvifUrl));
223
+ const currentAvifSrc = computed(() => (
224
+ isFallback.value ? avifPlaceholders[ placeholderType.value ] : props.card.imgAvifUrl
225
+ ));
221
226
 
222
227
  function getParameter(name: string) {
223
228
  return props.card.parameters?.find(p => p.name === name)?.values?.[ 0 ]?.name;
@@ -31,7 +31,7 @@
31
31
 
32
32
  <script setup lang="ts">
33
33
  import type { ISearchTopModelsType } from '../../types';
34
- import { convertString, pickPortraitVariant } from '../../runtime';
34
+ import { convertString } from '../../runtime';
35
35
  import DOMPurify from 'dompurify';
36
36
 
37
37
  const props = defineProps<{
@@ -42,14 +42,10 @@ const props = defineProps<{
42
42
  }>();
43
43
 
44
44
  /** Иконка 32px — квадратный кроп по лицу, если он есть в ответе. */
45
- const icon = computed(() => {
46
- const square = pickPortraitVariant(props.item?.portraits)?.square;
47
-
48
- return {
49
- webp: square?.webp || '/img/model-placeholder.webp',
50
- avif: square?.avif,
51
- };
52
- });
45
+ const icon = computed(() => ({
46
+ webp: props.item?.imageUrl || '/img/model-placeholder-square.webp',
47
+ avif: props.item?.imageUrl ? props.item.imageAvifUrl : '/img/model-placeholder-square.avif',
48
+ }));
53
49
 
54
50
  const title = computed(() => props.item?.title?.replace(/<[^>]*>/g, ''));
55
51
  const { generateLink } = useGenerateLink();
@@ -143,6 +143,8 @@ const topModels = computed(() => {
143
143
  title: item.title,
144
144
  name: item.title,
145
145
  videosCount: item.videosCount,
146
+ imageUrl: item.primaryImageSmallUrl,
147
+ imageAvifUrl: item.primaryImageSmallAvifUrl,
146
148
  })) || [];
147
149
  });
148
150
 
@@ -38,6 +38,7 @@
38
38
  <CardRound
39
39
  :card="item"
40
40
  :placeholder-avatar="placeholderAvatar"
41
+ :placeholder-avatar-avif="placeholderAvatarAvif"
41
42
  :without-count="withoutCount"
42
43
  class="section-swiper-cards__card"
43
44
  />
@@ -79,6 +80,8 @@ withDefaults(defineProps<{
79
80
  /** Вариант заголовка — см. `UiSectionTitle` */
80
81
  variant?: 'page' | 'section'
81
82
  placeholderAvatar?: string
83
+ /** Та же заглушка в avif — см. `CardRound` */
84
+ placeholderAvatarAvif?: string
82
85
  /** Карточки только с аватаром и названием, без счётчика роликов */
83
86
  withoutCount?: boolean
84
87
  }>(), {
@@ -24,12 +24,13 @@
24
24
  <UiImg
25
25
  v-if="item.image"
26
26
  :src="item.image"
27
+ :avif-src="item.imageAvif"
27
28
  :alt="name"
28
29
  :class="`--${imageFit}`"
29
30
  :width="imageBox.width"
30
31
  :height="imageBox.height"
31
32
  class="ui-chips__img"
32
- sizes="20px sm:24px"
33
+ sizes="auto, 24px"
33
34
  />
34
35
  {{ name }}
35
36
  <UiIcon
@@ -92,7 +92,8 @@
92
92
  v-if="similarModels && similarModels.length > 0"
93
93
  :items="modelsRoundCards"
94
94
  :title="$t('similar_models')"
95
- placeholder-avatar="/img/model-placeholder.webp"
95
+ placeholder-avatar="/img/model-placeholder-square.webp"
96
+ placeholder-avatar-avif="/img/model-placeholder-square.avif"
96
97
  class="view-model-videos__other-models"
97
98
  hydrate-on-visible
98
99
  />
@@ -6,7 +6,8 @@
6
6
  v-if="topCards.length >= MINIMUM_TOP_THUMBS"
7
7
  :items="topCards"
8
8
  class="view-models__popular"
9
- placeholder-avatar="/img/model-placeholder.webp"
9
+ placeholder-avatar="/img/model-placeholder-square.webp"
10
+ placeholder-avatar-avif="/img/model-placeholder-square.avif"
10
11
  only-grid
11
12
  />
12
13
 
@@ -91,7 +91,8 @@
91
91
  :items="modelsRoundCards"
92
92
  :title="$t('related_models')"
93
93
  class="view-video__popular-models"
94
- placeholder-avatar="/img/model-placeholder.webp"
94
+ placeholder-avatar="/img/model-placeholder-square.webp"
95
+ placeholder-avatar-avif="/img/model-placeholder-square.avif"
95
96
  hydrate-on-visible
96
97
  />
97
98
 
@@ -203,11 +204,14 @@ const topChips = computed(() => {
203
204
 
204
205
  const topChipsStrings = computed(() => topChips.value?.map(item => item.value));
205
206
 
207
+ /** Портрет в чипсе — квадратный кроп по лицу; своего у видео нет, его подмешивает BFF. */
206
208
  function getModelChips(model: IVideoData['models'][number]): IChipsItem {
207
209
  return {
208
210
  prefix: '/models',
209
211
  value: model.title,
210
212
  title: model.title,
213
+ image: model.imageUrl || '/img/model-placeholder-square.webp',
214
+ imageAvif: model.imageUrl ? model.imageAvifUrl : '/img/model-placeholder-square.avif',
211
215
  };
212
216
  }
213
217
  </script>
@@ -49,7 +49,8 @@
49
49
  :all-link-text="$t('show_all_models')"
50
50
  :header-link-text-short="$t('all_models')"
51
51
  class="view-videos__popular-models"
52
- placeholder-avatar="/img/model-placeholder.webp"
52
+ placeholder-avatar="/img/model-placeholder-square.webp"
53
+ placeholder-avatar-avif="/img/model-placeholder-square.avif"
53
54
  hydrate-on-visible
54
55
  />
55
56
  </GridVideos>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.985",
4
+ "version": "0.0.988",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -55,7 +55,8 @@
55
55
  :items="modelsRoundCards"
56
56
  :title="$t('category_top_models_title', {name: title})"
57
57
  class="category__models"
58
- placeholder-avatar="/img/model-placeholder.webp"
58
+ placeholder-avatar="/img/model-placeholder-square.webp"
59
+ placeholder-avatar-avif="/img/model-placeholder-square.avif"
59
60
  />
60
61
  </template>
61
62
  </GridVideos>
@@ -52,7 +52,8 @@
52
52
  :items="modelsRoundCards"
53
53
  :title="t('channels_popular_models')"
54
54
  class="channel__videos-models"
55
- placeholder-avatar="/img/model-placeholder.webp"
55
+ placeholder-avatar="/img/model-placeholder-square.webp"
56
+ placeholder-avatar-avif="/img/model-placeholder-square.avif"
56
57
  hydrate-on-visible
57
58
  />
58
59
  </template>
@@ -18,7 +18,8 @@
18
18
  all-link="/library/models"
19
19
  icon="user"
20
20
  variant="section"
21
- placeholder-avatar="/img/model-placeholder.webp"
21
+ placeholder-avatar="/img/model-placeholder-square.webp"
22
+ placeholder-avatar-avif="/img/model-placeholder-square.avif"
22
23
  class="following__section"
23
24
  without-count
24
25
  />
@@ -49,7 +49,8 @@
49
49
  <template #default="item">
50
50
  <CardRound
51
51
  :card="item"
52
- placeholder-avatar="/img/model-placeholder.webp"
52
+ placeholder-avatar="/img/model-placeholder-square.webp"
53
+ placeholder-avatar-avif="/img/model-placeholder-square.avif"
53
54
  />
54
55
  </template>
55
56
  <template
package/runtime/index.ts CHANGED
@@ -37,7 +37,8 @@ export * from './utils/cleaners/clean-profile-data';
37
37
  export * from './utils/cleaners/clean-video-data';
38
38
  export * from './utils/cleaners/clean-video-card';
39
39
  export * from './utils/cleaners/clean-model-info';
40
- export * from './utils/video-data-add-model-icon';
40
+ export * from './utils/cleaners/clean-suggest-models';
41
+ export * from './utils/video-data-add-model-portraits';
41
42
  export * from './utils/converters/convert-categories-to-footer';
42
43
  export * from './utils/converters/convert-categories-to-chips';
43
44
  export * from './utils/converters/convert-model-card-to-chips';
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { videoDataAddModelPortraits } from '../video-data-add-model-portraits';
3
+
4
+ const portraits = {
5
+ dark: {
6
+ tall: { width: 300, height: 450, webp: 'a-tall.webp', avif: 'a-tall.avif' },
7
+ square: { width: 200, height: 200, webp: 'a-square.webp', avif: 'a-square.avif' },
8
+ },
9
+ };
10
+
11
+ describe('videoDataAddModelPortraits', () => {
12
+ it('добавляет portraits к matching модели', () => {
13
+ const videoData = {
14
+ models: [
15
+ { guid: 'm-1', title: 'Model A' },
16
+ { guid: 'm-2', title: 'Model B' },
17
+ ],
18
+ };
19
+ const modelsData = [
20
+ { guid: 'm-1', portraits },
21
+ ];
22
+ const result = videoDataAddModelPortraits(videoData as any, modelsData as any);
23
+ expect(result.models[0].portraits).toEqual(portraits);
24
+ expect(result.models[1].portraits).toBeUndefined();
25
+ });
26
+
27
+ it('карточка без портретов ничего не добавляет', () => {
28
+ const videoData = { models: [{ guid: 'm-1', title: 'A' }] };
29
+ const modelsData = [{ guid: 'm-1', primary_image_url: 'https://img.com/a.jpg' }];
30
+ const result = videoDataAddModelPortraits(videoData as any, modelsData as any);
31
+ expect(result.models[0].portraits).toBeUndefined();
32
+ });
33
+
34
+ it('не мутирует исходный объект', () => {
35
+ const videoData = { models: [{ guid: 'm-1', title: 'A' }] };
36
+ const modelsData = [{ guid: 'm-1', portraits }];
37
+ const result = videoDataAddModelPortraits(videoData as any, modelsData as any);
38
+ expect(result).not.toBe(videoData);
39
+ expect((videoData.models[0] as any).portraits).toBeUndefined();
40
+ });
41
+
42
+ it('пустой массив моделей', () => {
43
+ const videoData = { models: [{ guid: 'm-1', title: 'A' }] };
44
+ const result = videoDataAddModelPortraits(videoData as any, []);
45
+ expect(result.models[0].portraits).toBeUndefined();
46
+ });
47
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { cleanSuggestModels } from '../clean-suggest-models';
3
+
4
+ const portraits = {
5
+ dark: {
6
+ tall: { width: 640, height: 960, webp: 'tall.webp', avif: 'tall.avif' },
7
+ square: { width: 256, height: 256, webp: 'square.webp', avif: 'square.avif' },
8
+ },
9
+ };
10
+
11
+ describe('cleanSuggestModels', () => {
12
+ it('берёт квадратный кроп, вертикальный игнорирует', () => {
13
+ const result = cleanSuggestModels([
14
+ { title: 'Alex Ink', videosCount: 5, portraits },
15
+ ] as any);
16
+
17
+ expect(result).toEqual([
18
+ { title: 'Alex Ink', videosCount: 5, imageUrl: 'square.webp', imageAvifUrl: 'square.avif' },
19
+ ]);
20
+ });
21
+
22
+ it('модель без портретов остаётся без картинки', () => {
23
+ const result = cleanSuggestModels([{ title: 'Alex Rim', videosCount: 1 }] as any);
24
+
25
+ expect(result).toEqual([{ title: 'Alex Rim', videosCount: 1 }]);
26
+ });
27
+
28
+ it('только вертикальный портрет — картинки нет', () => {
29
+ const result = cleanSuggestModels([
30
+ { title: 'A', videosCount: 2, portraits: { dark: { tall: portraits.dark.tall } } },
31
+ ] as any);
32
+
33
+ expect(result).toEqual([{ title: 'A', videosCount: 2 }]);
34
+ });
35
+
36
+ it('пустой список остаётся null, чтобы не всплыл таб «models»', () => {
37
+ expect(cleanSuggestModels(null)).toBeNull();
38
+ expect(cleanSuggestModels(undefined as any)).toBeNull();
39
+ });
40
+ });
@@ -13,7 +13,7 @@ describe('cleanVideoData', () => {
13
13
  likes: 100,
14
14
  id: 'id-1',
15
15
  tags: 'tag1',
16
- models: 'model1',
16
+ models: [{ guid: 'm-1', title: 'Model A' }],
17
17
  categories: 'cat1',
18
18
  actions: { liked: true },
19
19
  channel: { name: 'Channel', avatar_url: 'https://img.com/ch.jpg' },
@@ -37,6 +37,51 @@ describe('cleanVideoData', () => {
37
37
  expect(result.videoUrl).toBe('/stream');
38
38
  expect(result.isDeleted).toBe(false);
39
39
  expect(result.thumbNum).toBe(5);
40
+ expect(result.models).toEqual([{ guid: 'm-1', title: 'Model A' }]);
41
+ });
42
+
43
+ it('модели: квадратный кроп портрета в imageUrl/imageAvifUrl', () => {
44
+ const raw = {
45
+ models: [
46
+ {
47
+ guid: 'm-1',
48
+ title: 'Model A',
49
+ portraits: {
50
+ dark: {
51
+ tall: { width: 300, height: 450, webp: 'tall.webp', avif: 'tall.avif' },
52
+ square: { width: 200, height: 200, webp: 'square.webp', avif: 'square.avif' },
53
+ },
54
+ },
55
+ },
56
+ { guid: 'm-2', title: 'Model B' },
57
+ ],
58
+ };
59
+ const result = cleanVideoData(raw as any);
60
+ expect(result.models[0]).toEqual({
61
+ guid: 'm-1',
62
+ title: 'Model A',
63
+ imageUrl: 'square.webp',
64
+ imageAvifUrl: 'square.avif',
65
+ });
66
+ expect(result.models[1]).toEqual({ guid: 'm-2', title: 'Model B' });
67
+ });
68
+
69
+ it('модели: только вертикальный портрет — картинки нет', () => {
70
+ const raw = {
71
+ models: [
72
+ {
73
+ guid: 'm-1',
74
+ title: 'Model A',
75
+ portraits: { dark: { tall: { width: 300, height: 450, webp: 'tall.webp', avif: 'tall.avif' } } },
76
+ },
77
+ ],
78
+ };
79
+ const result = cleanVideoData(raw as any);
80
+ expect(result.models[0]).toEqual({ guid: 'm-1', title: 'Model A' });
81
+ });
82
+
83
+ it('моделей нет вовсе', () => {
84
+ expect(cleanVideoData({} as any).models).toEqual([]);
40
85
  });
41
86
 
42
87
  it('таймлайны: конец периода из бекендного end_duration', () => {
@@ -0,0 +1,23 @@
1
+ import type { ISuggestModel, IRawSuggestModel } from '../../../types';
2
+ import { pickPortraitVariant } from '../pick-portrait-variant';
3
+
4
+ /**
5
+ * Приводит модели из multi-suggest к готовым ссылкам на портрет: подсказке нужен квадратный
6
+ * кроп по лицу, вертикальный `tall` в иконку 32px не годится. Пустой список бек отдаёт как
7
+ * `null` — сохраняем как есть, иначе в поиске появится пустой таб «models».
8
+ * @param models - модели из ответа multi-suggest, уже приведённого к camelCase
9
+ * @param theme - тема сайта: ею выбирается вариант портрета
10
+ */
11
+ export const cleanSuggestModels = (models: IRawSuggestModel[] | null, theme?: string): ISuggestModel[] | null => {
12
+ if (!Array.isArray(models)) return models ?? null;
13
+
14
+ return models.map((model) => {
15
+ const square = pickPortraitVariant(model.portraits, theme)?.square;
16
+
17
+ return {
18
+ title: model.title,
19
+ videosCount: model.videosCount,
20
+ ...(square?.webp ? { imageUrl: square.webp, imageAvifUrl: square.avif } : {}),
21
+ };
22
+ });
23
+ };
@@ -1,4 +1,5 @@
1
1
  import type { IRawVideoData, IVideoData, ISceneGroup, ITimelineGroup } from '../../../types';
2
+ import { pickPortraitVariant } from '../pick-portrait-variant';
2
3
 
3
4
  // «locations» → «Locations», «living_room» → «Living room».
4
5
  const humanize = (value?: string | null): string => {
@@ -45,8 +46,24 @@ const cleanHotspots = (raw: string[] | null): number[] | null => {
45
46
  return values.length ? values : null;
46
47
  };
47
48
 
49
+ // Моделям видео нужен только квадратный кроп по лицу — он идёт в чипсу под заголовком.
50
+ // Вертикальный `tall` сюда не годится, как и в круглые аватары.
51
+ const cleanVideoModels = (models: IRawVideoData['models'], theme?: string): IVideoData['models'] => {
52
+ if (!Array.isArray(models)) return [];
53
+
54
+ return models.map((model) => {
55
+ const square = pickPortraitVariant(model.portraits, theme)?.square;
56
+
57
+ return {
58
+ guid: model.guid,
59
+ title: model.title,
60
+ ...(square?.webp ? { imageUrl: square.webp, imageAvifUrl: square.avif } : {}),
61
+ };
62
+ });
63
+ };
64
+
48
65
  /** Нормализует сырые данные видео к типу IVideoData. */
49
- export const cleanVideoData = (data: IRawVideoData): IVideoData => ({
66
+ export const cleanVideoData = (data: IRawVideoData, theme?: string): IVideoData => ({
50
67
  guid: data.guid || '',
51
68
  md5: data.md5 || '',
52
69
  duration: data.duration || 0,
@@ -56,7 +73,7 @@ export const cleanVideoData = (data: IRawVideoData): IVideoData => ({
56
73
  likes: data.likes || 0,
57
74
  id: data.id || '',
58
75
  tags: data.tags || [],
59
- models: data.models || [],
76
+ models: cleanVideoModels(data.models, theme),
60
77
  categories: data.categories || [],
61
78
  actions: data.actions || null,
62
79
  channelName: data.channel?.name || '',
@@ -0,0 +1,27 @@
1
+ import type { IRawModelCard, IRawVideoData } from '../../types';
2
+
3
+ /**
4
+ * Подмешивает portraits из карточек моделей в каждую модель videoData.models по совпадению guid.
5
+ * Ответ видео своих портретов не отдаёт — их приносит параллельный запрос
6
+ * `models/list-by-video-id|url`, а вариант выбирает уже клинер.
7
+ * @param videoData - сырые данные видео
8
+ * @param modelsData - массив карточек моделей с portraits
9
+ */
10
+ export function videoDataAddModelPortraits(videoData: IRawVideoData, modelsData: Array<IRawModelCard>): IRawVideoData {
11
+ const transformedData = { ...videoData };
12
+
13
+ transformedData.models = transformedData.models?.map((model) => {
14
+ const modelData = modelsData.find((item) => item.guid === model.guid);
15
+
16
+ if (modelData?.portraits) {
17
+ return {
18
+ ...model,
19
+ portraits: modelData.portraits,
20
+ };
21
+ }
22
+
23
+ return model;
24
+ });
25
+
26
+ return transformedData;
27
+ }
@@ -1,7 +1,8 @@
1
- import { getUrlWithProxiedParams, convertSnakeKeysToCamel } from '../../../runtime';
2
- import type { IMultiSuggest } from '../../../types';
1
+ import { getUrlWithProxiedParams, convertSnakeKeysToCamel, cleanSuggestModels } from '../../../runtime';
2
+ import type { IMultiSuggest, IRawMultiSuggest } from '../../../types';
3
3
 
4
- export default defineEventHandler(async (event) => {
4
+ export default defineEventHandler(async (event): Promise<IMultiSuggest> => {
5
+ const theme = useRuntimeConfig(event).public.theme;
5
6
  const apiUrl = process.env.NUXT_PUBLIC_API_DOMAIN;
6
7
  const url = getUrlWithProxiedParams(`${apiUrl}/v2/search/multi-suggest`, event);
7
8
 
@@ -10,5 +11,10 @@ export default defineEventHandler(async (event) => {
10
11
  headers: getRequestHeaders(event) as HeadersInit,
11
12
  });
12
13
 
13
- return convertSnakeKeysToCamel<IMultiSuggest>(rawData.data);
14
+ const data = convertSnakeKeysToCamel<IRawMultiSuggest>(rawData.data);
15
+
16
+ return {
17
+ ...data,
18
+ models: cleanSuggestModels(data?.models, theme),
19
+ };
14
20
  });
@@ -1,7 +1,8 @@
1
1
  import type { IRawVideoData, IVideoData, IRawModelCard } from '../../../../types';
2
- import { cleanVideoData, videoDataAddModelIcon, getUrlWithProxiedParams } from '../../../../runtime';
2
+ import { cleanVideoData, videoDataAddModelPortraits, getUrlWithProxiedParams } from '../../../../runtime';
3
3
 
4
4
  export default defineEventHandler(async (event): Promise<IVideoData> => {
5
+ const theme = useRuntimeConfig(event).public.theme;
5
6
  const apiUrl = process.env.NUXT_PUBLIC_API_DOMAIN;
6
7
  const id = getRouterParam(event, 'id');
7
8
 
@@ -26,5 +27,8 @@ export default defineEventHandler(async (event): Promise<IVideoData> => {
26
27
  const videoData = videoRaw.data as IRawVideoData;
27
28
  const modelsData = modelsRaw?.data as Record<'items', Array<IRawModelCard>>;
28
29
 
29
- return cleanVideoData(videoData?.models ? videoDataAddModelIcon(videoData, modelsData?.items || []) : videoData);
30
+ return cleanVideoData(
31
+ videoData?.models ? videoDataAddModelPortraits(videoData, modelsData?.items || []) : videoData,
32
+ theme,
33
+ );
30
34
  });
@@ -1,7 +1,8 @@
1
- import { cleanVideoData, videoDataAddModelIcon, getUrlWithProxiedParams } from '../../../../runtime';
1
+ import { cleanVideoData, videoDataAddModelPortraits, getUrlWithProxiedParams } from '../../../../runtime';
2
2
  import type { IRawVideoData, IVideoData, IRawModelCard } from '../../../../types';
3
3
 
4
4
  export default defineEventHandler(async (event): Promise<IVideoData> => {
5
+ const theme = useRuntimeConfig(event).public.theme;
5
6
  const apiUrl = process.env.NUXT_PUBLIC_API_DOMAIN;
6
7
  const url = getRouterParam(event, 'url');
7
8
 
@@ -25,5 +26,8 @@ export default defineEventHandler(async (event): Promise<IVideoData> => {
25
26
  const videoData = videoRaw.data as IRawVideoData;
26
27
  const modelsData = modelsRaw?.data as Record<'items', Array<IRawModelCard>>;
27
28
 
28
- return cleanVideoData(videoData?.models ? videoDataAddModelIcon(videoData, modelsData?.items || []) : videoData);
29
+ return cleanVideoData(
30
+ videoData?.models ? videoDataAddModelPortraits(videoData, modelsData?.items || []) : videoData,
31
+ theme,
32
+ );
29
33
  });
@@ -9,5 +9,7 @@ export interface IChipsItem {
9
9
  key?: string
10
10
  query?: string
11
11
  image?: string
12
+ /** Та же картинка в avif: есть — UiImg рендерит `<picture>`, `image` становится фолбэком. */
13
+ imageAvif?: string
12
14
  imageFit?: ChipsImageFits
13
15
  }
@@ -1,3 +1,5 @@
1
+ import type { IRawPortraits } from './raw/raw-portraits';
2
+
1
3
  interface AboutItem {
2
4
  key: string;
3
5
  sizeCache: string;
@@ -62,9 +64,19 @@ interface Category {
62
64
  videosCount: number;
63
65
  }
64
66
 
65
- interface Model {
67
+ /** Модель, как её отдаёт multi-suggest: с портретами, но до выбора варианта. */
68
+ export interface IRawSuggestModel {
66
69
  title: string;
67
70
  videosCount: number;
71
+ portraits?: IRawPortraits;
72
+ }
73
+
74
+ /** Та же модель после `cleanSuggestModels`: квадратный кроп уже выбран. */
75
+ export interface ISuggestModel {
76
+ title: string;
77
+ videosCount: number;
78
+ imageUrl?: string;
79
+ imageAvifUrl?: string;
68
80
  }
69
81
 
70
82
  interface Stream {
@@ -96,10 +108,16 @@ interface StripcashModel {
96
108
  viewersCount: number;
97
109
  }
98
110
 
111
+ /** Ответ multi-suggest после BFF: модели уже прошли `cleanSuggestModels`. */
99
112
  export interface IMultiSuggest {
100
113
  awempireModels: AwempireModel[];
101
114
  categories: Category[];
102
- models: Model[];
115
+ models: ISuggestModel[] | null;
103
116
  searchPhrases: string[];
104
117
  stripcashModels: StripcashModel[];
105
118
  }
119
+
120
+ /** Тот же ответ до чистки моделей — с ним работает хендлер. */
121
+ export interface IRawMultiSuggest extends Omit<IMultiSuggest, 'models'> {
122
+ models: IRawSuggestModel[] | null;
123
+ }
@@ -1,4 +1,5 @@
1
1
  import type { IRawThumbUrls } from './raw-thumbs-urls';
2
+ import type { IRawPortraits } from './raw-portraits';
2
3
  import type { ITimelineGroup } from '../video-data';
3
4
 
4
5
  export interface IRawVideoData {
@@ -21,6 +22,8 @@ export interface IRawVideoData {
21
22
  models: {
22
23
  guid: string;
23
24
  title: string;
25
+ /** Своего поля у видео нет — портреты подмешивает BFF из карточек `models/list-by-video-*`. */
26
+ portraits?: IRawPortraits;
24
27
  }[];
25
28
  categories: {
26
29
  icon?: string,
@@ -1,11 +1,8 @@
1
- import type { IRawPortraits } from './raw/raw-portraits';
2
-
3
1
  export interface ISearchTopModelsType {
4
2
  videosCount?: number
5
3
  name: string
6
4
  title: string
7
- primaryImageUrl?: string
8
- primaryImageAvifUrl?: string
9
- /** Приходит из multi-suggest как есть: там общий snake→camel, отдельного клинера нет. */
10
- portraits?: IRawPortraits
5
+ /** Квадратный кроп портрета: и multi-suggest, и `models/top` отдают его уже выбранным. */
6
+ imageUrl?: string
7
+ imageAvifUrl?: string
11
8
  }
@@ -49,6 +49,9 @@ export interface IVideoData {
49
49
  models: {
50
50
  guid: string;
51
51
  title: string;
52
+ /** Квадратный кроп портрета под чипсу модели: его подмешивает BFF, в ответе видео его нет. */
53
+ imageUrl?: string;
54
+ imageAvifUrl?: string;
52
55
  }[];
53
56
  categories: {
54
57
  name: string;
@@ -1,32 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { videoDataAddModelIcon } from '../video-data-add-model-icon';
3
-
4
- describe('videoDataAddModelIcon', () => {
5
- it('добавляет icon к matching модели', () => {
6
- const videoData = {
7
- models: [
8
- { guid: 'm-1', title: 'Model A' },
9
- { guid: 'm-2', title: 'Model B' },
10
- ],
11
- };
12
- const modelsData = [
13
- { guid: 'm-1', primary_image_url: 'https://img.com/a.jpg' },
14
- ];
15
- const result = videoDataAddModelIcon(videoData as any, modelsData as any);
16
- expect(result.models[0].icon).toBe('https://img.com/a.jpg');
17
- expect(result.models[1].icon).toBeUndefined();
18
- });
19
-
20
- it('не мутирует исходный объект', () => {
21
- const videoData = { models: [{ guid: 'm-1', title: 'A' }] };
22
- const modelsData = [{ guid: 'm-1', primary_image_url: 'url' }];
23
- const result = videoDataAddModelIcon(videoData as any, modelsData as any);
24
- expect(result).not.toBe(videoData);
25
- });
26
-
27
- it('пустой массив моделей', () => {
28
- const videoData = { models: [{ guid: 'm-1', title: 'A' }] };
29
- const result = videoDataAddModelIcon(videoData as any, []);
30
- expect(result.models[0].icon).toBeUndefined();
31
- });
32
- });
@@ -1,25 +0,0 @@
1
- import type { IRawModelCard, IRawVideoData } from '../../types';
2
-
3
- /**
4
- * Добавляет поле icon из modelsData в каждую модель videoData.models по совпадению guid.
5
- * @param videoData - сырые данные видео
6
- * @param modelsData - массив карточек моделей с primary_image_url
7
- */
8
- export function videoDataAddModelIcon(videoData: IRawVideoData, modelsData: Array<IRawModelCard>): IRawVideoData {
9
- const transformedData = { ...videoData };
10
-
11
- transformedData.models = transformedData.models?.map((model) => {
12
- const modelData = modelsData.find((item) => item.guid === model.guid);
13
-
14
- if (modelData) {
15
- return {
16
- ...model,
17
- icon: modelData.primary_image_url,
18
- };
19
- }
20
-
21
- return model;
22
- });
23
-
24
- return transformedData;
25
- }