itube-specs 0.0.968 → 0.0.970
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/card/model-mini.vue +2 -0
- package/components/card/model.vue +2 -0
- package/components/card/round.vue +2 -0
- package/components/player/aside-videos.vue +4 -2
- package/components/ui/dropdown.vue +92 -1
- package/components/ui/img.vue +11 -9
- package/composables/use-test-model-images.ts +25 -27
- package/package.json +1 -1
- package/pages/search/[slug]/videos.vue +1 -1
- package/runtime/index.ts +0 -1
- package/runtime/utils/__tests__/build-srcset.test.ts +7 -0
- package/runtime/utils/cleaners/__tests__/clean-model-card.test.ts +3 -17
- package/runtime/utils/cleaners/clean-model-card.ts +1 -9
- package/runtime/utils/converters/convert-model-to-round-card.ts +1 -0
- package/types/model-card.d.ts +2 -0
- package/types/model-image-urls.d.ts +11 -5
- package/types/raw/raw-model-image-urls.d.ts +3 -6
- package/types/round-card.d.ts +2 -0
- package/runtime/constants/model-image-size.ts +0 -6
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
<UiImg
|
|
5
5
|
:src="currentImage"
|
|
6
6
|
:image-urls="currentImageUrls"
|
|
7
|
+
:avif-src="currentAvifSrc"
|
|
7
8
|
:alt="card.title"
|
|
8
9
|
class="card-model-mini__img"
|
|
9
10
|
width="48"
|
|
@@ -61,6 +62,7 @@ const currentImage = computed(() => {
|
|
|
61
62
|
});
|
|
62
63
|
|
|
63
64
|
const currentImageUrls = computed(() => (imageError.value ? undefined : props.card.primaryImageUrls));
|
|
65
|
+
const currentAvifSrc = computed(() => (imageError.value ? undefined : props.card.primaryImageAvifUrl));
|
|
64
66
|
</script>
|
|
65
67
|
|
|
66
68
|
<style lang="scss">
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
<UiImg
|
|
10
10
|
:src="currentImage"
|
|
11
11
|
:image-urls="currentImageUrls"
|
|
12
|
+
:avif-src="currentAvifSrc"
|
|
12
13
|
:loading="loading"
|
|
13
14
|
:alt="card.title"
|
|
14
15
|
:priority="priority"
|
|
@@ -108,6 +109,7 @@ const currentImage = computed(() => {
|
|
|
108
109
|
|
|
109
110
|
/** На заглушке ладдер сбрасываем — иначе браузер продолжит тянуть битые кандидаты. */
|
|
110
111
|
const currentImageUrls = computed(() => (imageError.value ? undefined : props.card.primaryImageUrls));
|
|
112
|
+
const currentAvifSrc = computed(() => (imageError.value ? undefined : props.card.primaryImageAvifUrl));
|
|
111
113
|
|
|
112
114
|
function onImageError() {
|
|
113
115
|
imageError.value = true;
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
<UiImg
|
|
8
8
|
:src="currentImage"
|
|
9
9
|
:image-urls="currentImageUrls"
|
|
10
|
+
:avif-src="currentAvifSrc"
|
|
10
11
|
:loading="loading"
|
|
11
12
|
:alt="card.title"
|
|
12
13
|
:priority="priority"
|
|
@@ -70,6 +71,7 @@ const currentImage = computed(() => {
|
|
|
70
71
|
});
|
|
71
72
|
|
|
72
73
|
const currentImageUrls = computed(() => (imageError.value ? undefined : props.card.primaryImageUrls));
|
|
74
|
+
const currentAvifSrc = computed(() => (imageError.value ? undefined : props.card.primaryImageAvifUrl));
|
|
73
75
|
|
|
74
76
|
function onImageError() {
|
|
75
77
|
imageError.value = true;
|
|
@@ -89,8 +89,10 @@ defineProps<{
|
|
|
89
89
|
inset: 0;
|
|
90
90
|
overflow-y: auto;
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
/* Контейнер прокрутки обрезает всё, что элементы рисуют ЗА своей границей, —
|
|
93
|
+
кольцо аватара модели и рамки фокуса. Отступ в 2px даёт им место; справа
|
|
94
|
+
к нему добавлено расстояние до полосы прокрутки. */
|
|
95
|
+
padding: 2px 6px 2px 2px;
|
|
94
96
|
}
|
|
95
97
|
}
|
|
96
98
|
</style>
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
{'--center': center},
|
|
9
9
|
{'--open': open},
|
|
10
10
|
{'--separate': separateLastChild},
|
|
11
|
+
{'--escaped': escaped},
|
|
11
12
|
]"
|
|
12
13
|
class="ui-dropdown"
|
|
13
14
|
@mouseleave="mouseHandler(false)"
|
|
@@ -26,7 +27,10 @@
|
|
|
26
27
|
<dialog
|
|
27
28
|
v-if="useVIf ? open && $slots.items : true"
|
|
28
29
|
ref="menuRef"
|
|
29
|
-
:style="
|
|
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
|
|
package/components/ui/img.vue
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<!-- `<picture>` только
|
|
2
|
+
<!-- `<picture>` только когда есть avif: обёртка сдвигает картинку на уровень ниже, а
|
|
3
3
|
`card/playlist.vue` и `playlist-mini.vue` целятся в свои UiImg через `:first-child`/`:nth-child(2)`
|
|
4
4
|
— внутри `<picture>` каждая из них стала бы первым ребёнком, и раскладка поехала бы. -->
|
|
5
5
|
<picture
|
|
6
|
-
v-if="
|
|
6
|
+
v-if="avifSrc"
|
|
7
7
|
class="ui-img"
|
|
8
8
|
>
|
|
9
9
|
<source
|
|
10
|
-
:srcset="
|
|
11
|
-
:sizes="computedSizes"
|
|
10
|
+
:srcset="avifSrc"
|
|
12
11
|
type="image/avif"
|
|
13
12
|
>
|
|
14
13
|
<img
|
|
@@ -31,11 +30,13 @@ defineOptions({ inheritAttrs: false });
|
|
|
31
30
|
|
|
32
31
|
const props = withDefaults(defineProps<{
|
|
33
32
|
src?: string,
|
|
33
|
+
/** Тот же кадр в avif. Есть — рендерится `<picture>`, `src` становится фолбэком. */
|
|
34
|
+
avifSrc?: string,
|
|
34
35
|
width: string | number,
|
|
35
36
|
height: string | number,
|
|
36
37
|
sizes?: string,
|
|
37
38
|
urls?: IThumbUrls,
|
|
38
|
-
/** Ладдер
|
|
39
|
+
/** Ладдер по ширине (портреты моделей). Старше `urls`. */
|
|
39
40
|
imageUrls?: IModelImageUrls,
|
|
40
41
|
alt?: string,
|
|
41
42
|
loading?: 'lazy' | 'eager',
|
|
@@ -53,10 +54,13 @@ const attrs = useAttrs();
|
|
|
53
54
|
* (апрель 2026), Safari 27 (сентябрь 2026). Где не поддержан — деградирует в `100vw`,
|
|
54
55
|
* и браузер берёт самый крупный кандидат. Для ладдеров, где это перерасход, вызывающий
|
|
55
56
|
* пишет `sizes` сам, начиная с `auto,` — остаток списка и есть фолбэк под Safari ≤26.
|
|
56
|
-
* Такой `sizes` отдаём как есть.
|
|
57
57
|
*/
|
|
58
58
|
const computedSizes = computed(() => {
|
|
59
|
-
if (props.sizes?.startsWith('auto'))
|
|
59
|
+
if (props.sizes?.startsWith('auto')) {
|
|
60
|
+
// `auto` валиден только при loading=lazy. На eager браузер бракует весь атрибут и
|
|
61
|
+
// уходит в дефолтные 100vw, то есть тянет самый крупный кандидат: отдаём хвост списка.
|
|
62
|
+
return props.loading === 'lazy' ? props.sizes : props.sizes.replace(/^auto,\s*/, '');
|
|
63
|
+
}
|
|
60
64
|
if (props.loading === 'lazy') return 'auto';
|
|
61
65
|
return props.sizes;
|
|
62
66
|
});
|
|
@@ -69,8 +73,6 @@ const imgSrc = computed(() => {
|
|
|
69
73
|
|
|
70
74
|
const computedSrcset = computed(() => buildSrcset(props.imageUrls?.webp) || buildSrcset(props.urls?.webp));
|
|
71
75
|
|
|
72
|
-
const avifSrcset = computed(() => buildSrcset(props.imageUrls?.avif));
|
|
73
|
-
|
|
74
76
|
const imgAttrs = computed(() => ({
|
|
75
77
|
...attrs,
|
|
76
78
|
src: imgSrc.value,
|
|
@@ -1,27 +1,33 @@
|
|
|
1
|
-
import type { IModelImageUrls } from '../types';
|
|
2
|
-
import { ModelImageSize } from '../runtime';
|
|
3
|
-
|
|
4
1
|
export interface ITestModelPortrait {
|
|
5
2
|
primaryImageUrl: string;
|
|
6
3
|
thumbUrl: string;
|
|
7
|
-
|
|
4
|
+
primaryImageAvifUrl?: string;
|
|
8
5
|
}
|
|
9
6
|
|
|
10
|
-
const PORTRAIT_COUNT =
|
|
7
|
+
const PORTRAIT_COUNT = 16;
|
|
11
8
|
|
|
12
9
|
/**
|
|
13
|
-
* Тестовые портреты моделей из `public/img/models/`.
|
|
14
|
-
*
|
|
10
|
+
* Тестовые портреты моделей из `public/img/models/`. Везде один файл 640w:
|
|
11
|
+
*
|
|
12
|
+
* - `?img=png` — исходники;
|
|
13
|
+
* - `?img=webp` — только webp, без avif-ветки;
|
|
14
|
+
* - без параметра — avif q63 4:4:4 через `<picture>`, фолбэк webp `-q 85 -m 6 -pass 10 -sharp_yuv`.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* Качество ступеней подобрано по равной ошибке в высоких частотах: webp q85 даёт 0.00405,
|
|
17
|
+
* avif q63 — 0.00399. Вес при этом 645 против 434 KB на 16 картинок, то есть avif легче на
|
|
18
|
+
* треть при том же качестве. Разрыв держится на всём диапазоне, от точки не зависит.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
20
|
+
* Один файл, а НЕ ступени через srcset. Исходники 640, слот карточки максимум 297 CSS, так
|
|
21
|
+
* что 640w закрывает любое устройство вплоть до DPR3. Ступень мельче слота при этом заведомо
|
|
22
|
+
* хуже полного файла: её портит двойной пересчёт (наш ресайз плюс браузерный), а её артефакты
|
|
23
|
+
* показываются почти 1:1, тогда как 640w в том же слоте ужимается в 2.6 раза и они
|
|
24
|
+
* размазываются. Проверено на DPR1-мониторе: ладдер мылит, один файл нет. Ступени станут
|
|
25
|
+
* осмысленны, только если исходники вырастут заметно выше 640.
|
|
22
26
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
27
|
+
* Кроп под 3:4 не делаем — обрезает `object-fit`. Почему именно так, см. `IModelImageUrls`.
|
|
28
|
+
*
|
|
29
|
+
* Читается один раз на монтировании, поэтому переключение — перезагрузкой, а не навигацией
|
|
30
|
+
* по query. Для замера сети это и нужно: он честен только с чистого захода.
|
|
25
31
|
*/
|
|
26
32
|
export function useTestModelImages() {
|
|
27
33
|
const format = useRoute().query['img'];
|
|
@@ -30,25 +36,17 @@ export function useTestModelImages() {
|
|
|
30
36
|
const base = `/img/models/portrait-${index + 1}`;
|
|
31
37
|
|
|
32
38
|
if (format === 'png') {
|
|
33
|
-
return { primaryImageUrl: `${base}.png`, thumbUrl: `${base}.png
|
|
39
|
+
return { primaryImageUrl: `${base}.png`, thumbUrl: `${base}.png` };
|
|
34
40
|
}
|
|
35
41
|
|
|
36
|
-
const ladder = (extension: string) => ({
|
|
37
|
-
[ModelImageSize.Small]: `${base}-300.${extension}`,
|
|
38
|
-
[ModelImageSize.Medium]: `${base}-600.${extension}`,
|
|
39
|
-
});
|
|
40
|
-
|
|
41
42
|
return {
|
|
42
|
-
primaryImageUrl: `${base}-
|
|
43
|
-
thumbUrl: `${base}-
|
|
44
|
-
|
|
45
|
-
avif: format === 'webp' ? undefined : ladder('avif'),
|
|
46
|
-
webp: ladder('webp'),
|
|
47
|
-
},
|
|
43
|
+
primaryImageUrl: `${base}-q85.webp`,
|
|
44
|
+
thumbUrl: `${base}-q85.webp`,
|
|
45
|
+
primaryImageAvifUrl: format === 'webp' ? undefined : `${base}-q63.avif`,
|
|
48
46
|
};
|
|
49
47
|
});
|
|
50
48
|
|
|
51
|
-
const portraitAt = (index: number) => portraits[index %
|
|
49
|
+
const portraitAt = (index: number) => portraits[index % portraits.length]!;
|
|
52
50
|
|
|
53
51
|
return { portraits, portraitAt };
|
|
54
52
|
}
|
package/package.json
CHANGED
|
@@ -51,7 +51,7 @@ const { meta, h1 } = useMeta({
|
|
|
51
51
|
t,
|
|
52
52
|
pageKey: pagePureName,
|
|
53
53
|
brandName: useAppConfig().brandName,
|
|
54
|
-
sortOptions: vectorSearch ?
|
|
54
|
+
sortOptions: vectorSearch ? sortItemsSearch.slice(0, 1) : sortItemsSearch,
|
|
55
55
|
text: metaTitle,
|
|
56
56
|
});
|
|
57
57
|
|
package/runtime/index.ts
CHANGED
|
@@ -8,7 +8,6 @@ export * from './constants/niche';
|
|
|
8
8
|
export * from './constants/report-forms-subjects';
|
|
9
9
|
export * from './constants/playlist-step';
|
|
10
10
|
export * from './constants/thumb-size';
|
|
11
|
-
export * from './constants/model-image-size';
|
|
12
11
|
export * from './constants/webcams';
|
|
13
12
|
export * from './utils/build-srcset';
|
|
14
13
|
export * from './utils/format-date';
|
|
@@ -2,6 +2,13 @@ import { describe, it, expect } from 'vitest';
|
|
|
2
2
|
import { buildSrcset } from '../build-srcset';
|
|
3
3
|
|
|
4
4
|
describe('buildSrcset', () => {
|
|
5
|
+
it('ключ-ширина без высоты — основная форма для портретов', () => {
|
|
6
|
+
expect(buildSrcset({
|
|
7
|
+
300: 'https://img.com/a-300.webp',
|
|
8
|
+
640: 'https://img.com/a-640.webp',
|
|
9
|
+
})).toBe('https://img.com/a-300.webp 300w, https://img.com/a-640.webp 640w');
|
|
10
|
+
});
|
|
11
|
+
|
|
5
12
|
it('собирает дескрипторы ширины из ключей карты', () => {
|
|
6
13
|
expect(buildSrcset({
|
|
7
14
|
'300x400': 'https://img.com/a-300.webp',
|
|
@@ -26,26 +26,12 @@ describe('cleanModelCard', () => {
|
|
|
26
26
|
expect(result.country).toEqual({ title: 'United States of America', name: 'us' });
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
it('primary_image_urls → ладдер
|
|
29
|
+
it('primary_image_urls → ладдер портрета как есть, ступени произвольные', () => {
|
|
30
30
|
const raw = makeRaw({
|
|
31
|
-
primary_image_urls: { webp: { '
|
|
31
|
+
primary_image_urls: { webp: { 300: 'https://img.com/p-300.webp', 640: 'https://img.com/p-640.webp' } },
|
|
32
32
|
});
|
|
33
33
|
expect(cleanModelCard(raw as any).primaryImageUrls).toEqual({
|
|
34
|
-
|
|
35
|
-
webp: { '300x400': '', '600x800': 'https://img.com/p-600.webp' },
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('avif-ветка попадает в ладдер', () => {
|
|
40
|
-
const raw = makeRaw({
|
|
41
|
-
primary_image_urls: {
|
|
42
|
-
avif: { '300x400': 'https://img.com/p-300.avif', '600x800': 'https://img.com/p-600.avif' },
|
|
43
|
-
webp: { '300x400': 'https://img.com/p-300.webp', '600x800': 'https://img.com/p-600.webp' },
|
|
44
|
-
},
|
|
45
|
-
});
|
|
46
|
-
expect(cleanModelCard(raw as any).primaryImageUrls?.avif).toEqual({
|
|
47
|
-
'300x400': 'https://img.com/p-300.avif',
|
|
48
|
-
'600x800': 'https://img.com/p-600.avif',
|
|
34
|
+
webp: { 300: 'https://img.com/p-300.webp', 640: 'https://img.com/p-640.webp' },
|
|
49
35
|
});
|
|
50
36
|
});
|
|
51
37
|
|
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import type { IRawModelCard, IModelCard, IModelParameter } from '../../../types';
|
|
2
2
|
import { ThumbSize } from '../../constants/thumb-size';
|
|
3
|
-
import { ModelImageSize } from '../../constants/model-image-size';
|
|
4
3
|
|
|
5
4
|
const parameterNames = ['guy_type', 'ethnicity'];
|
|
6
|
-
const pickPortraitSizes = (urls?: Partial<Record<ModelImageSize, string>>) => urls && {
|
|
7
|
-
[ModelImageSize.Small]: urls[ModelImageSize.Small] || '',
|
|
8
|
-
[ModelImageSize.Medium]: urls[ModelImageSize.Medium] || '',
|
|
9
|
-
};
|
|
10
5
|
const firstValue = (card: IRawModelCard, name: string) => card.parameters?.find(item => item.name === name)?.values?.[0];
|
|
11
6
|
const age = (card: IRawModelCard) => firstValue(card, 'age')?.title;
|
|
12
7
|
|
|
@@ -16,10 +11,7 @@ export const cleanModelCard = (card: IRawModelCard): IModelCard => ({
|
|
|
16
11
|
videosCount: card.videos_count || 0,
|
|
17
12
|
thumbUrl: card.video_thumb_urls?.webp?.[ThumbSize.Small] || '',
|
|
18
13
|
primaryImageUrl: card.primary_image_url || '',
|
|
19
|
-
primaryImageUrls: card.primary_image_urls?.webp ? {
|
|
20
|
-
avif: pickPortraitSizes(card.primary_image_urls.avif),
|
|
21
|
-
webp: pickPortraitSizes(card.primary_image_urls.webp)!,
|
|
22
|
-
} : undefined,
|
|
14
|
+
primaryImageUrls: card.primary_image_urls?.webp ? { webp: card.primary_image_urls.webp } : undefined,
|
|
23
15
|
guid: card.guid || '',
|
|
24
16
|
// Страна лежит в parameters под именем nationality_iso_codes: `name` — код ISO-2 (`us`),
|
|
25
17
|
// он же имя файла флага, `title` — читаемое название. Одноимённое поле верхнего уровня
|
|
@@ -7,6 +7,7 @@ export function convertModelToRoundCard(model: IModelCard): IRoundCard {
|
|
|
7
7
|
title: model.title,
|
|
8
8
|
primaryImageUrl: model.primaryImageUrl || model.thumbUrl || '',
|
|
9
9
|
primaryImageUrls: model.primaryImageUrls,
|
|
10
|
+
primaryImageAvifUrl: model.primaryImageAvifUrl,
|
|
10
11
|
videosCount: model.videosCount,
|
|
11
12
|
link: `/models/${convertString().toSlug(model.title)}`,
|
|
12
13
|
};
|
package/types/model-card.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export interface IModelCard {
|
|
|
5
5
|
videosCount: number;
|
|
6
6
|
thumbUrl: string;
|
|
7
7
|
primaryImageUrl?: string;
|
|
8
|
+
/** Тот же портрет в avif. Есть — карточка отдаёт его через `<picture>`, webp фолбэком. */
|
|
9
|
+
primaryImageAvifUrl?: string;
|
|
8
10
|
/** Ладдер портрета под srcset. Нет — карточка рендерит один `primaryImageUrl`. */
|
|
9
11
|
primaryImageUrls?: IModelImageUrls;
|
|
10
12
|
guid: string;
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Ладдер портрета модели. Ключ — ширина в пикселях, высота не фиксирована.
|
|
3
|
+
*
|
|
4
|
+
* Портреты НЕ приводятся к 3:4 нарезкой — обрезает `object-fit` в карточке. Предварительный
|
|
5
|
+
* кроп менял габариты файла, а вместе с ними и цепочку уменьшения в браузере, и картинка
|
|
6
|
+
* заметно мылила при тех же самых пикселях. Проверено на живой вёрстке.
|
|
7
|
+
*
|
|
8
|
+
* Только webp: avif на портретах проигрывал по точности и почти не выигрывал по весу, а
|
|
9
|
+
* держать ради него `<picture>` в `UiImg` не окупалось.
|
|
10
|
+
*/
|
|
4
11
|
export interface IModelImageUrls {
|
|
5
|
-
|
|
6
|
-
webp: Partial<Record<ModelImageSize, string>>;
|
|
12
|
+
webp: Record<string, string>;
|
|
7
13
|
}
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
/** Raw портрет модели из API — зеркало `video_thumb_urls`, но с портретными размерами */
|
|
1
|
+
/** Raw портрет модели из API. Ключ — ширина в пикселях, см. `IModelImageUrls`. */
|
|
4
2
|
export interface IRawModelImageUrls {
|
|
5
|
-
jpeg?:
|
|
6
|
-
webp?:
|
|
7
|
-
avif?: Partial<Record<ModelImageSize, string>>;
|
|
3
|
+
jpeg?: Record<string, string>;
|
|
4
|
+
webp?: Record<string, string>;
|
|
8
5
|
}
|
package/types/round-card.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type { IModelImageUrls } from './model-image-urls';
|
|
|
3
3
|
export interface IRoundCard {
|
|
4
4
|
title: string
|
|
5
5
|
primaryImageUrl: string
|
|
6
|
+
/** Тот же портрет в avif. Есть — карточка отдаёт его через `<picture>`, webp фолбэком. */
|
|
7
|
+
primaryImageAvifUrl?: string
|
|
6
8
|
/** Ладдер портрета под srcset. Нет — карточка рендерит один `primaryImageUrl`. */
|
|
7
9
|
primaryImageUrls?: IModelImageUrls
|
|
8
10
|
videosCount: number
|