perf(movimento): vitrine e 3D mais leves, WebGL por software lembrado na sessão e suporte ao WebKit
This commit is contained in:
1 parent
9fd1570426
commit
8903f3c57e
28 files changed
+854
-406
No files matched your search
@@ -1,11 +1,14 @@
|
||||
import {
|
||||
isCoarsePointer,
|
||||
isKnownSoftwareWebGL,
|
||||
isLowPowerDevice,
|
||||
isMobileViewport,
|
||||
isSaveData,
|
||||
isSoftwareRenderer,
|
||||
isSoftwareWebGL,
|
||||
isWebKit,
|
||||
MOBILE_QUERY,
|
||||
rememberSoftwareWebGL,
|
||||
} from "./device";
|
||||
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
@@ -122,3 +125,97 @@ describe("software WebGL", () => {
|
||||
expect(isSoftwareWebGL(context(null))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the software WebGL verdict kept for the session", () => {
|
||||
beforeEach(() => sessionStorage.clear());
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it("is unknown until someone remembers it, then known for the rest of the session", () => {
|
||||
expect(isKnownSoftwareWebGL()).toBe(false);
|
||||
|
||||
rememberSoftwareWebGL();
|
||||
|
||||
expect(isKnownSoftwareWebGL()).toBe(true);
|
||||
// fica no sessionStorage: some com a aba, e não vale para outra aparelhagem
|
||||
expect(sessionStorage.getItem("webgl-software")).toBe("1");
|
||||
});
|
||||
|
||||
it("ignores any other value in the key (only the verdict this module writes counts)", () => {
|
||||
sessionStorage.setItem("webgl-software", "0");
|
||||
|
||||
expect(isKnownSoftwareWebGL()).toBe(false);
|
||||
});
|
||||
|
||||
it("counts a blocked sessionStorage as unknown, and remembering then does not throw", () => {
|
||||
jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new DOMException("blocked", "SecurityError");
|
||||
});
|
||||
jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new DOMException("blocked", "SecurityError");
|
||||
});
|
||||
|
||||
expect(isKnownSoftwareWebGL()).toBe(false);
|
||||
expect(() => rememberSoftwareWebGL()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWebKit (the only engine that needs the showcase video to play once)", () => {
|
||||
const iphone =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1";
|
||||
const iphoneChrome =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/126.0 Mobile/15E148 Safari/604.1";
|
||||
const macSafari =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15";
|
||||
const chrome =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
|
||||
const edge = `${chrome} Edg/126.0.0.0`;
|
||||
const androidChrome =
|
||||
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36";
|
||||
const firefox =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0";
|
||||
|
||||
afterEach(() => {
|
||||
mockNavigator({ platform: "", maxTouchPoints: 0 });
|
||||
Reflect.deleteProperty(navigator, "userAgent");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Safari on the iPhone", iphone],
|
||||
[
|
||||
"Chrome on the iPhone (iOS runs WebKit under every browser)",
|
||||
iphoneChrome,
|
||||
],
|
||||
["Safari on the Mac", macSafari],
|
||||
])("is true for %s", (_name, userAgent) => {
|
||||
mockNavigator({ userAgent });
|
||||
|
||||
expect(isWebKit()).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Chrome", chrome],
|
||||
["Edge", edge],
|
||||
["Chrome on Android", androidChrome],
|
||||
["Firefox", firefox],
|
||||
])("is false for %s", (_name, userAgent) => {
|
||||
mockNavigator({ userAgent });
|
||||
|
||||
expect(isWebKit()).toBe(false);
|
||||
});
|
||||
|
||||
it("is true for an iPad that presents itself as a Mac (a Mac with a touch screen)", () => {
|
||||
mockNavigator({
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101",
|
||||
platform: "MacIntel",
|
||||
maxTouchPoints: 5,
|
||||
});
|
||||
|
||||
expect(isWebKit()).toBe(true);
|
||||
mockNavigator({ maxTouchPoints: 0 });
|
||||
expect(isWebKit()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -72,3 +72,45 @@ export function isSoftwareWebGL(
|
||||
const renderer: unknown = context.getParameter(info.UNMASKED_RENDERER_WEBGL);
|
||||
return typeof renderer === "string" && isSoftwareRenderer(renderer);
|
||||
}
|
||||
|
||||
const SOFTWARE_WEBGL_KEY = "webgl-software";
|
||||
|
||||
/**
|
||||
* O WebGL deste aparelho já se mostrou por software nesta sessão (aba)? Abrir um contexto só para descobrir custa uma
|
||||
* tarefa longa (150 ms na GPU, 500 ms ou mais por software), então quem abre o contexto de verdade anota o veredito
|
||||
* aqui e as páginas seguintes da sessão já nascem estáticas. `sessionStorage` bloqueado vale como "não sei".
|
||||
*/
|
||||
export function isKnownSoftwareWebGL() {
|
||||
try {
|
||||
return sessionStorage.getItem(SOFTWARE_WEBGL_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Anota na sessão que o WebGL deste aparelho desenha por software (ver `isKnownSoftwareWebGL`). */
|
||||
export function rememberSoftwareWebGL() {
|
||||
try {
|
||||
sessionStorage.setItem(SOFTWARE_WEBGL_KEY, "1");
|
||||
} catch {
|
||||
// armazenamento bloqueado (modo privado, política do navegador): sem anotação, a próxima página testa de novo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* O navegador é WebKit (Safari no Mac, no iPhone e no iPad; no iOS todo navegador é WebKit)? É o único que deixa em
|
||||
* branco um vídeo que ainda não tocou uma vez, mesmo com o quadro já decodificado: só nele vale tocar e pausar o
|
||||
* vídeo da vitrine para ele aparecer, e tocar faz o arquivo inteiro ser baixado (ver PinnedReel).
|
||||
*/
|
||||
export function isWebKit() {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
const { userAgent, platform, maxTouchPoints } = navigator;
|
||||
// o iPad com "site para computador" se apresenta como Mac, mas é o único Mac com toque
|
||||
const iOS =
|
||||
/iP(hone|ad|od)/.test(userAgent) ||
|
||||
(platform === "MacIntel" && maxTouchPoints > 1);
|
||||
const safari =
|
||||
/Safari\//.test(userAgent) &&
|
||||
!/Chrome|Chromium|Edg|OPR|Android/.test(userAgent);
|
||||
return iOS || safari;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
chapterAnchors,
|
||||
chapterFraction,
|
||||
chapterIndexAt,
|
||||
chapterIndexAtScroll,
|
||||
chapterScrollFraction,
|
||||
desktopFractionAt,
|
||||
getOgImagePath,
|
||||
getScrubSource,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
mobileFractionAt,
|
||||
PROJECT_VIDEO_IDS,
|
||||
SHOWCASE,
|
||||
videoFractionAtScroll,
|
||||
} from "./project-showcase";
|
||||
|
||||
const MAX_BYTES = 3 * 1024 * 1024;
|
||||
@@ -20,21 +23,33 @@ const publicFile = (path: string) => join(process.cwd(), "public", path);
|
||||
const HOME_IDS = ["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"];
|
||||
|
||||
describe("showcase length", () => {
|
||||
it.each(SHOWCASE)("$id has at most three chapters", (config) => {
|
||||
expect(MAX_CHAPTERS).toBe(3);
|
||||
it.each(SHOWCASE)("$id has at most two chapters", (config) => {
|
||||
expect(MAX_CHAPTERS).toBe(2);
|
||||
expect(config.chapters.length).toBeLessThanOrEqual(MAX_CHAPTERS);
|
||||
expect(config.chapters.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("pins the four home projects for about three and a half screens at most", () => {
|
||||
it("gives every project the same scroll, half a screen for its two chapters", () => {
|
||||
expect(SHOWCASE.map((config) => config.weight)).toEqual(
|
||||
SHOWCASE.map(() => 0.5),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the whole Projects section within about three and a half screens", () => {
|
||||
// o casco da vitrine tem (pin: 1 tela menos o cabeçalho) + (soma dos pesos) telas; o título e o botão de Projetos
|
||||
// somam ~468 px (0,52 tela de 900 px) fora dela, e o cabeçalho fixo tem 68 px
|
||||
const SCREEN = 900;
|
||||
const HEADER = 68;
|
||||
const OUTSIDE = 468;
|
||||
const total = SHOWCASE.filter((config) => HOME_IDS.includes(config.id))
|
||||
.map((config) => config.weight)
|
||||
.reduce((sum, weight) => sum + weight, 0);
|
||||
expect(HOME_IDS.every((id) => SHOWCASE.some((c) => c.id === id))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(total).toBeLessThanOrEqual(3.5);
|
||||
expect(total).toBeGreaterThan(2);
|
||||
const section = SCREEN - HEADER + total * SCREEN + OUTSIDE;
|
||||
expect(section / SCREEN).toBeLessThanOrEqual(3.5);
|
||||
expect(total).toBeGreaterThan(1.5);
|
||||
});
|
||||
|
||||
it("keeps the NutriScan configuration for /cases", () => {
|
||||
@@ -90,13 +105,72 @@ describe("showcase chapters", () => {
|
||||
expect(chapterIndexAt(lavacao, 0)).toBe(0);
|
||||
expect(chapterIndexAt(lavacao, 3 / lavacao.durations.desktop)).toBe(0);
|
||||
expect(chapterIndexAt(lavacao, 10 / lavacao.durations.desktop)).toBe(1);
|
||||
expect(chapterIndexAt(lavacao, 25 / lavacao.durations.desktop)).toBe(2);
|
||||
expect(chapterIndexAt(lavacao, 25 / lavacao.durations.desktop)).toBe(1);
|
||||
expect(chapterIndexAt(lavacao, 1)).toBe(lavacao.chapters.length - 1);
|
||||
expect(chapterIndexAt(lavacao, 2)).toBe(lavacao.chapters.length - 1);
|
||||
expect(chapterIndexAt(lavacao, -1)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("showcase scroll: every chapter gets the same share", () => {
|
||||
const lavacao = SHOWCASE.find((config) => config.id === "lavacao-047");
|
||||
const asServicos = SHOWCASE.find((config) => config.id === "as-servicos");
|
||||
if (!lavacao || !asServicos) throw new Error("projeto fora da vitrine");
|
||||
|
||||
it.each(SHOWCASE)(
|
||||
"$id: each chapter starts at its own time of the video, at i/n of the scroll",
|
||||
(config) => {
|
||||
config.chapters.forEach((chapter, i) => {
|
||||
expect(
|
||||
videoFractionAtScroll(config, chapterScrollFraction(config, i)),
|
||||
).toBeCloseTo(chapter.desktop / config.durations.desktop, 9);
|
||||
});
|
||||
expect(videoFractionAtScroll(config, 0)).toBe(0);
|
||||
expect(videoFractionAtScroll(config, 1)).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("splits a 30 s project with a 5.5 s first chapter in half of the scroll, not in 18% and 82%", () => {
|
||||
const half = videoFractionAtScroll(lavacao, 0.5);
|
||||
|
||||
expect(half).toBeCloseTo(5.5 / lavacao.durations.desktop, 9);
|
||||
// dentro de cada metade o vídeo anda de forma linear
|
||||
expect(videoFractionAtScroll(lavacao, 0.25)).toBeCloseTo(half / 2, 9);
|
||||
expect(videoFractionAtScroll(lavacao, 0.75)).toBeCloseTo((half + 1) / 2, 9);
|
||||
});
|
||||
|
||||
it("only goes forward, even for a project whose chapters are very different in length", () => {
|
||||
let last = -1;
|
||||
for (let progress = 0; progress <= 1; progress += 0.01) {
|
||||
const fraction = videoFractionAtScroll(asServicos, progress);
|
||||
expect(fraction).toBeGreaterThanOrEqual(last);
|
||||
last = fraction;
|
||||
}
|
||||
});
|
||||
|
||||
it("reads the chapter from the scroll: n equal parts, clamped", () => {
|
||||
expect(chapterIndexAtScroll(lavacao, 0)).toBe(0);
|
||||
expect(chapterIndexAtScroll(lavacao, 0.49)).toBe(0);
|
||||
expect(chapterIndexAtScroll(lavacao, 0.5)).toBe(1);
|
||||
expect(chapterIndexAtScroll(lavacao, 1)).toBe(lavacao.chapters.length - 1);
|
||||
expect(chapterIndexAtScroll(lavacao, 3)).toBe(lavacao.chapters.length - 1);
|
||||
expect(chapterIndexAtScroll(lavacao, -1)).toBe(0);
|
||||
// a conta de ponto flutuante na fronteira (1/3 de 3 partes) não derruba o capítulo
|
||||
const three = {
|
||||
...lavacao,
|
||||
chapters: [...lavacao.chapters, lavacao.chapters[1]],
|
||||
};
|
||||
expect(chapterIndexAtScroll(three, 1 / 3)).toBe(1);
|
||||
});
|
||||
|
||||
it("gives the scroll fraction where a chapter starts (and clamps the index)", () => {
|
||||
expect(chapterScrollFraction(lavacao, 0)).toBe(0);
|
||||
expect(chapterScrollFraction(lavacao, 1)).toBe(0.5);
|
||||
expect(chapterScrollFraction(lavacao, 9)).toBe(0.5);
|
||||
expect(chapterScrollFraction(lavacao, -2)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("showcase assets", () => {
|
||||
it("picks the version for the device", () => {
|
||||
expect(getShowcaseAssets("nutriscan", "desktop").poster).toBe(
|
||||
|
||||
+51
-16
@@ -25,29 +25,32 @@ export interface ShowcaseConfig {
|
||||
accent: string;
|
||||
/** Duração dos vídeos gravados, em segundos (os capítulos são medidos nelas). */
|
||||
durations: { desktop: number; mobile: number };
|
||||
/** Comprimento da rolagem deste projeto na vitrine, em alturas de tela. */
|
||||
/** Comprimento da rolagem deste projeto na vitrine, em alturas de tela (o mesmo em todos: ver SHOWCASE). */
|
||||
weight: number;
|
||||
chapters: readonly ShowcaseChapter[];
|
||||
}
|
||||
|
||||
/** Máximo de capítulos por projeto na vitrine: três marcas bastam para situar o visitante sem virar índice. */
|
||||
export const MAX_CHAPTERS = 3;
|
||||
/** Máximo de capítulos por projeto na vitrine: dois bastam (de onde o site parte e o trecho que o define). */
|
||||
export const MAX_CHAPTERS = 2;
|
||||
|
||||
/**
|
||||
* Ordem da vitrine = ordem do SHOWCASE para os projetos que o provedor recebe. Capítulos marcados nos quadros
|
||||
* dos próprios vídeos, no máximo MAX_CHAPTERS por projeto (os mais representativos, com os tempos originais).
|
||||
* O peso soma cerca de 3,5 alturas de tela para os quatro projetos de cliente da home (sev-modaintima,
|
||||
* lavacao-047, sc-plus e as-servicos): a vitrine inteira prende pouco a rolagem.
|
||||
* dos próprios vídeos, no máximo MAX_CHAPTERS por projeto, com os tempos originais: a abertura e o trecho principal,
|
||||
* o par que deixa menos segundos de vídeo sob o rótulo do outro (o último trecho vai até o fim do vídeo).
|
||||
*
|
||||
* O peso é o mesmo em todos, meia altura de tela por projeto, um quarto de tela por capítulo: os quatro projetos de
|
||||
* cliente da home (sev-modaintima, lavacao-047, sc-plus e as-servicos) prendem 2 telas de rolagem e a seção Projetos
|
||||
* inteira, com o título e o botão, fica em cerca de 3,4 telas (eram 4,9). Dentro do projeto a rolagem também é
|
||||
* repartida por igual entre os capítulos (ver `videoFractionAtScroll`), qualquer que seja a duração deles no vídeo.
|
||||
*/
|
||||
export const SHOWCASE: readonly ShowcaseConfig[] = [
|
||||
{
|
||||
id: "sev-modaintima",
|
||||
accent: "#c2303a",
|
||||
durations: { desktop: 14.4, mobile: 30.17 },
|
||||
weight: 0.95,
|
||||
weight: 0.5,
|
||||
chapters: [
|
||||
{ label: "Abertura", desktop: 0, mobile: 0 },
|
||||
{ label: "As favoritas", desktop: 3.6, mobile: 3.8 },
|
||||
{ label: "Coleção completa", desktop: 8, mobile: 9.2 },
|
||||
],
|
||||
},
|
||||
@@ -55,11 +58,10 @@ export const SHOWCASE: readonly ShowcaseConfig[] = [
|
||||
id: "lavacao-047",
|
||||
accent: "#4aa3ff",
|
||||
durations: { desktop: 30.07, mobile: 30.1 },
|
||||
weight: 1.1,
|
||||
weight: 0.5,
|
||||
chapters: [
|
||||
{ label: "Abertura", desktop: 0, mobile: 0 },
|
||||
{ label: "Do sujo ao brilho", desktop: 5.5, mobile: 5.6 },
|
||||
{ label: "Perguntas e contato", desktop: 24, mobile: 22 },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -67,32 +69,29 @@ export const SHOWCASE: readonly ShowcaseConfig[] = [
|
||||
id: "nutriscan",
|
||||
accent: "#2f9e5f",
|
||||
durations: { desktop: 15.67, mobile: 26.4 },
|
||||
weight: 0.8,
|
||||
weight: 0.5,
|
||||
chapters: [
|
||||
{ label: "Busca", desktop: 0, mobile: 0 },
|
||||
{ label: "Quatro etapas", desktop: 7, mobile: 8 },
|
||||
{ label: "Apoio ao projeto", desktop: 10.9, mobile: 16.3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "sc-plus",
|
||||
accent: "#22c55e",
|
||||
durations: { desktop: 14.7, mobile: 22.4 },
|
||||
weight: 0.8,
|
||||
weight: 0.5,
|
||||
chapters: [
|
||||
{ label: "Abertura", desktop: 0, mobile: 0 },
|
||||
{ label: "Áreas e projetos", desktop: 5.8, mobile: 7.6 },
|
||||
{ label: "Contato", desktop: 12.6, mobile: 19.4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "as-servicos",
|
||||
accent: "#2563eb",
|
||||
durations: { desktop: 5.07, mobile: 7.7 },
|
||||
weight: 0.6,
|
||||
weight: 0.5,
|
||||
chapters: [
|
||||
{ label: "Três áreas", desktop: 0, mobile: 0 },
|
||||
{ label: "Critérios de entrega", desktop: 1.5, mobile: 3 },
|
||||
{ label: "Contato", desktop: 2.4, mobile: 4.4 },
|
||||
],
|
||||
},
|
||||
@@ -187,6 +186,42 @@ export function desktopFractionAt(config: ShowcaseConfig, fraction: number) {
|
||||
return interpolate(chapterAnchors(config), fraction, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pontos [fração da rolagem do projeto, fração do vídeo desktop] da vitrine fixa: o capítulo `i` de `n` começa em
|
||||
* `i / n` da rolagem, quando o vídeo chega ao ponto em que o capítulo começa nele. Assim cada capítulo ganha o mesmo
|
||||
* tanto de rolagem, dure ele 0,9 s ou 24 s no vídeo (o vídeo anda mais depressa nos longos).
|
||||
*/
|
||||
function scrollAnchors(config: ShowcaseConfig): Array<[number, number]> {
|
||||
const { chapters, durations } = config;
|
||||
const points = chapters.map((chapter, i): [number, number] => [
|
||||
i / chapters.length,
|
||||
chapter.desktop / durations.desktop,
|
||||
]);
|
||||
points.push([1, 1]);
|
||||
return points;
|
||||
}
|
||||
|
||||
/** Fração do vídeo desktop (0 a 1) que a rolagem do projeto, em `progress` (0 a 1), mostra: capítulos de rolagem igual. */
|
||||
export function videoFractionAtScroll(
|
||||
config: ShowcaseConfig,
|
||||
progress: number,
|
||||
) {
|
||||
return interpolate(scrollAnchors(config), progress, 0);
|
||||
}
|
||||
|
||||
/** Capítulo em exibição quando a rolagem do projeto está em `progress` (0 a 1): `n` partes iguais. */
|
||||
export function chapterIndexAtScroll(config: ShowcaseConfig, progress: number) {
|
||||
const count = config.chapters.length;
|
||||
const index = Math.floor(Math.min(1, Math.max(0, progress)) * count + 1e-6);
|
||||
return Math.min(count - 1, index);
|
||||
}
|
||||
|
||||
/** Fração da rolagem do projeto (0 a 1) em que o capítulo `index` começa. */
|
||||
export function chapterScrollFraction(config: ShowcaseConfig, index: number) {
|
||||
const { length } = config.chapters;
|
||||
return Math.min(length - 1, Math.max(0, index)) / length;
|
||||
}
|
||||
|
||||
/** Capítulo em exibição quando o vídeo desktop está na fração `fraction`. */
|
||||
export function chapterIndexAt(config: ShowcaseConfig, fraction: number) {
|
||||
const seconds = Math.min(1, Math.max(0, fraction)) * config.durations.desktop;
|
||||
|
||||
Reference in new issue
Block a user