itube-specs 0.0.969 → 0.0.970

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