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,4 +1,5 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { setNavigation } from "@/__mocks__/next/navigation";
|
||||
import CinematicOverlays from "./CinematicOverlays";
|
||||
|
||||
/**
|
||||
@@ -6,9 +7,6 @@ import CinematicOverlays from "./CinematicOverlays";
|
||||
* com a página montada (de fora da home para a home e de volta) e as duas condições do grão juntas.
|
||||
*/
|
||||
|
||||
let mockPathname = "/";
|
||||
jest.mock("next/navigation", () => ({ usePathname: () => mockPathname }));
|
||||
|
||||
const mockLoaded = jest.fn();
|
||||
jest.mock("./FilmGrain", () => {
|
||||
mockLoaded();
|
||||
@@ -38,7 +36,7 @@ function mockMedia(active: string[] = []) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockPathname = "/";
|
||||
setNavigation({ pathname: "/" });
|
||||
mockLoaded.mockClear();
|
||||
mockMedia();
|
||||
});
|
||||
@@ -49,12 +47,12 @@ afterEach(() => {
|
||||
|
||||
describe("CinematicOverlays: troca de página", () => {
|
||||
it("path: arriving on the home from another page with a mouse -> the grain and the progress bar appear", async () => {
|
||||
mockPathname = "/cases";
|
||||
setNavigation({ pathname: "/cases" });
|
||||
const { container, rerender } = render(<CinematicOverlays />);
|
||||
await loaded();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
mockPathname = "/";
|
||||
setNavigation({ pathname: "/" });
|
||||
rerender(<CinematicOverlays />);
|
||||
await loaded();
|
||||
|
||||
@@ -67,12 +65,12 @@ describe("CinematicOverlays: troca de página", () => {
|
||||
await loaded();
|
||||
expect(screen.getByTestId("grain")).toBeInTheDocument();
|
||||
|
||||
mockPathname = "/servicos";
|
||||
setNavigation({ pathname: "/servicos" });
|
||||
rerender(<CinematicOverlays />);
|
||||
expect(screen.queryByTestId("grain")).toBeNull();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
mockPathname = "/";
|
||||
setNavigation({ pathname: "/" });
|
||||
rerender(<CinematicOverlays />);
|
||||
await loaded();
|
||||
expect(screen.getByTestId("grain")).toBeInTheDocument();
|
||||
@@ -80,7 +78,7 @@ describe("CinematicOverlays: troca de página", () => {
|
||||
});
|
||||
|
||||
it("path: a sub-route that merely starts like the home path -> not the home", async () => {
|
||||
mockPathname = "/cases/sev-modaintima";
|
||||
setNavigation({ pathname: "/cases/sev-modaintima" });
|
||||
const { container } = render(<CinematicOverlays />);
|
||||
await loaded();
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { setNavigation } from "@/__mocks__/next/navigation";
|
||||
import CinematicOverlays from "./CinematicOverlays";
|
||||
|
||||
let mockPathname = "/";
|
||||
jest.mock("next/navigation", () => ({ usePathname: () => mockPathname }));
|
||||
|
||||
// o grão vem por import dinâmico (pacote próprio): aqui ele vira um marcador, e o contador diz se o pacote foi pedido
|
||||
const mockLoaded = jest.fn();
|
||||
jest.mock("./FilmGrain", () => {
|
||||
@@ -34,7 +32,7 @@ function mockMedia(active: string[] = []) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockPathname = "/";
|
||||
setNavigation({ pathname: "/" });
|
||||
mockMedia();
|
||||
});
|
||||
|
||||
@@ -68,7 +66,7 @@ describe("CinematicOverlays: where the grain package is never requested", () =>
|
||||
});
|
||||
|
||||
it("shows nothing, and requests nothing, on the other pages", async () => {
|
||||
mockPathname = "/cases";
|
||||
setNavigation({ pathname: "/cases" });
|
||||
const { container } = render(<CinematicOverlays />);
|
||||
await loaded();
|
||||
|
||||
|
||||
@@ -19,8 +19,9 @@ export type FramePulseOptions = {
|
||||
* Batida das cenas WebGL em demanda: pede um quadro (`invalidate`) a no máximo `fps` por segundo, de um único
|
||||
* `setInterval` (fundo ambiente, sem gastar 60 quadros por segundo e sem um segundo laço de requestAnimationFrame ao
|
||||
* lado do da cena). Depois de `idleMs` sem cursor, rolagem nem teclado, a batida para de vez (nenhum intervalo, nenhum
|
||||
* quadro) e o último quadro fica na tela; qualquer movimento a religa. Quem precisa de mais (entrada, rolagem, cursor)
|
||||
* pede o próximo quadro por conta própria, dentro do quadro. Devolve a função que desliga tudo.
|
||||
* quadro) e o último quadro fica na tela; qualquer movimento a religa. É o único relógio do cursor (nenhum
|
||||
* requestAnimationFrame encadeado a cada quadro da tela); quem precisa de mais (entrada, rolagem) pede o próximo quadro
|
||||
* por conta própria, dentro do quadro. Devolve a função que desliga tudo.
|
||||
*/
|
||||
export function startFramePulse(
|
||||
invalidate: () => void,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { rememberSoftwareWebGL } from "@/lib/device";
|
||||
import HeroField, { DIVE_QUERY } from "./HeroField";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de HeroField (complemento de HeroField.test.tsx): como a espera pelo WebGL acaba
|
||||
* (primeira interação, requestIdleCallback com folga ou sem folga, temporizador), os cancelamentos, as falhas
|
||||
* (contexto WebGL2, pacote que não carrega), o fundo sem a seção `.hero`, as variáveis de rolagem e a
|
||||
* (cena que não abre o contexto, pacote que não carrega), o WebGL por software lembrado na sessão, o fundo sem a seção `.hero`, as variáveis de rolagem e a
|
||||
* visibilidade (observador, aba oculta, fim do mergulho) e a limpeza ao desmontar.
|
||||
*/
|
||||
|
||||
@@ -306,25 +307,13 @@ describe("HeroField: quando o WebGL monta", () => {
|
||||
});
|
||||
|
||||
describe("HeroField: falhas voltam ao hero simples", () => {
|
||||
it("path: WebGL2 constructor exists but creating the context throws -> static background, no dive", async () => {
|
||||
setContext(() => {
|
||||
throw new Error("sem GPU");
|
||||
});
|
||||
|
||||
it("path: the scene fails to open its WebGL context (it reports an error) -> static background, no dive, no scene", async () => {
|
||||
render(<Hero />);
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
expect(section()).toHaveAttribute("data-dive");
|
||||
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
});
|
||||
|
||||
it("path: WebGL2 constructor exists but the context is null -> static background, no dive", async () => {
|
||||
setContext(() => null);
|
||||
|
||||
render(<Hero />);
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
act(() => mockScenes.at(-1)?.onError());
|
||||
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
@@ -342,28 +331,16 @@ describe("HeroField: falhas voltam ao hero simples", () => {
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
});
|
||||
|
||||
it("path: the probe context is released right away (WEBGL2 lose_context) before the real scene mounts", async () => {
|
||||
const loseContext = jest.fn();
|
||||
setContext(() => ({
|
||||
getExtension: (name: string) =>
|
||||
name === "WEBGL_lose_context" ? { loseContext } : null,
|
||||
}));
|
||||
|
||||
render(<Hero />);
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(loseContext).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: the lose-context extension is missing -> the probe still succeeds", async () => {
|
||||
setContext(() => ({ getExtension: () => null }));
|
||||
it("path: HeroField never touches a canvas context, on hydration or when idle (a test context cost a 150 to 500 ms task)", async () => {
|
||||
const probe = jest.fn(() => ({ getExtension: () => null }));
|
||||
setContext(probe);
|
||||
|
||||
render(<Hero />);
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -601,61 +578,48 @@ describe("HeroField: desmontagem em pontos que faltavam", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** Um contexto WebGL de teste que conta ao navegador o nome do renderizador (WEBGL_debug_renderer_info). */
|
||||
function contextRenderedBy(renderer: unknown, loseContext = jest.fn()) {
|
||||
const RENDERER = 37446;
|
||||
return {
|
||||
getExtension: (name: string) => {
|
||||
if (name === "WEBGL_debug_renderer_info")
|
||||
return { UNMASKED_RENDERER_WEBGL: RENDERER };
|
||||
return name === "WEBGL_lose_context" ? { loseContext } : null;
|
||||
},
|
||||
getParameter: (parameter: number) =>
|
||||
parameter === RENDERER ? renderer : undefined,
|
||||
};
|
||||
}
|
||||
describe("HeroField: WebGL por software (o veredito vem da cena e fica na sessão)", () => {
|
||||
beforeEach(() => sessionStorage.clear());
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe("HeroField: WebGL2 por software (supportsWebGL2 -> isSoftwareWebGL)", () => {
|
||||
it.each([
|
||||
[
|
||||
"SwiftShader (Chrome sem GPU)",
|
||||
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)",
|
||||
],
|
||||
["llvmpipe (Mesa)", "llvmpipe (LLVM 15.0.7, 256 bits)"],
|
||||
[
|
||||
"Microsoft Basic Render Driver",
|
||||
"ANGLE (Microsoft, Microsoft Basic Render Driver Direct3D11 vs_5_0 ps_5_0)",
|
||||
],
|
||||
])(
|
||||
"path: %s -> the plain 100svh hero from hydration: no dive, no scroll listeners, no observer, no scene, and the probe context is still released",
|
||||
async (_name, renderer) => {
|
||||
const loseContext = jest.fn();
|
||||
setContext(() => contextRenderedBy(renderer, loseContext));
|
||||
const addListener = jest.spyOn(window, "addEventListener");
|
||||
it("path: software WebGL already found in this session -> the plain 100svh hero from hydration: no dive, no scroll listeners, no observer, no scene", async () => {
|
||||
rememberSoftwareWebGL();
|
||||
const addListener = jest.spyOn(window, "addEventListener");
|
||||
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("pointermove"));
|
||||
});
|
||||
await advance(3000);
|
||||
await finishLoading();
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("pointermove"));
|
||||
});
|
||||
await advance(3000);
|
||||
await finishLoading();
|
||||
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
expect(mockPrepare).not.toHaveBeenCalled();
|
||||
expect(observers).toHaveLength(0);
|
||||
expect(addListener.mock.calls.map(([type]) => type)).not.toContain(
|
||||
"scroll",
|
||||
);
|
||||
expect(loseContext).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("path: a hardware renderer -> the scene mounts and the dive starts", async () => {
|
||||
setContext(() =>
|
||||
contextRenderedBy("ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11)"),
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
expect(mockPrepare).not.toHaveBeenCalled();
|
||||
expect(observers).toHaveLength(0);
|
||||
expect(addListener.mock.calls.map(([type]) => type)).not.toContain(
|
||||
"scroll",
|
||||
);
|
||||
});
|
||||
|
||||
it("path: nothing known about this device yet -> the dive starts at hydration and the scene mounts", async () => {
|
||||
render(<Hero />);
|
||||
expect(section()).toHaveAttribute("data-dive");
|
||||
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: sessionStorage blocked (privacy mode) -> counts as unknown, the scene still mounts", async () => {
|
||||
jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new Error("SecurityError");
|
||||
});
|
||||
|
||||
render(<Hero />);
|
||||
await advance(1200);
|
||||
@@ -665,24 +629,16 @@ describe("HeroField: WebGL2 por software (supportsWebGL2 -> isSoftwareWebGL)", (
|
||||
expect(section()).toHaveAttribute("data-dive");
|
||||
});
|
||||
|
||||
it("path: no renderer info extension (Firefox, Safari) -> cannot tell, so the scene mounts", async () => {
|
||||
setContext(() => ({ getExtension: () => null }));
|
||||
|
||||
it("path: the scene finds software WebGL once it has opened its context (onError) -> back to the 100svh hero, no scene", async () => {
|
||||
render(<Hero />);
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
expect(section()).toHaveAttribute("data-dive");
|
||||
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
act(() => mockScenes.at(-1)?.onError());
|
||||
|
||||
it("path: the renderer name is not a string -> cannot tell, so the scene mounts", async () => {
|
||||
setContext(() => contextRenderedBy(null));
|
||||
|
||||
render(<Hero />);
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { rememberSoftwareWebGL } from "@/lib/device";
|
||||
import HeroField, { DIVE_QUERY } from "./HeroField";
|
||||
|
||||
type SceneProps = {
|
||||
@@ -38,18 +39,6 @@ function mockMedia(active: string[] = [DIVE_QUERY]) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Um WebGL2 que abre, e que diz na extensão de depuração em que renderizador desenha. */
|
||||
function mockWebGLRenderer(renderer: string) {
|
||||
HTMLCanvasElement.prototype.getContext = (() => ({
|
||||
getExtension: (name: string) =>
|
||||
name === "WEBGL_debug_renderer_info"
|
||||
? { UNMASKED_RENDERER_WEBGL: 37446 }
|
||||
: null,
|
||||
getParameter: (parameter: number) =>
|
||||
parameter === 37446 ? renderer : null,
|
||||
})) as unknown as typeof HTMLCanvasElement.prototype.getContext;
|
||||
}
|
||||
|
||||
function mockNavigator(values: Record<string, unknown>) {
|
||||
for (const [key, value] of Object.entries(values))
|
||||
Object.defineProperty(navigator, key, { configurable: true, value });
|
||||
@@ -94,6 +83,7 @@ async function idle() {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
sessionStorage.clear();
|
||||
mockScenes.length = 0;
|
||||
mockPrepare.mockClear();
|
||||
mockMedia();
|
||||
@@ -111,6 +101,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
sessionStorage.clear();
|
||||
window.matchMedia = original.matchMedia;
|
||||
(window as unknown as Record<string, unknown>).WebGL2RenderingContext =
|
||||
original.webgl2;
|
||||
@@ -129,6 +120,19 @@ describe("HeroField (ambient background)", () => {
|
||||
expect(mockScenes.at(-1)?.visible).toBe(true);
|
||||
});
|
||||
|
||||
it("opens no WebGL context of its own, on hydration or when idle: the only one is the scene's", async () => {
|
||||
const probe = jest.fn(() => ({ getExtension: () => null }));
|
||||
HTMLCanvasElement.prototype.getContext =
|
||||
probe as unknown as typeof HTMLCanvasElement.prototype.getContext;
|
||||
|
||||
render(<Hero />);
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
await idle();
|
||||
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("brings the geometry in before the Canvas, in a task of its own", async () => {
|
||||
render(<Hero />);
|
||||
|
||||
@@ -183,17 +187,11 @@ describe("HeroField (ambient background)", () => {
|
||||
undefined;
|
||||
},
|
||||
],
|
||||
...[
|
||||
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
|
||||
"llvmpipe (LLVM 15.0.7, 256 bits)",
|
||||
"ANGLE (Microsoft, Microsoft Basic Render Driver (0x0000008C) Direct3D11 vs_5_0 ps_5_0, D3D11)",
|
||||
].map(
|
||||
(renderer) =>
|
||||
[
|
||||
`software WebGL (${renderer.slice(0, 24)}...)`,
|
||||
() => mockWebGLRenderer(renderer),
|
||||
] as [string, () => void],
|
||||
),
|
||||
// o veredito vem do contexto da cena (HeroFieldScene), que o anota na sessão; a página seguinte já nasce estática
|
||||
[
|
||||
"a software WebGL found earlier in the session",
|
||||
() => rememberSoftwareWebGL(),
|
||||
],
|
||||
])(
|
||||
"keeps the light static background (no WebGL, no dive, no scroll work) with %s",
|
||||
async (_label, arrange) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { isLowPowerDevice, isSoftwareWebGL } from "@/lib/device";
|
||||
import { isKnownSoftwareWebGL, isLowPowerDevice } from "@/lib/device";
|
||||
import type { HeroFieldSceneProps } from "./HeroFieldScene";
|
||||
|
||||
/** A mesma condição do CSS do mergulho (globals.css): tela com altura para o conteúdo e sem movimento reduzido. */
|
||||
@@ -44,21 +44,12 @@ const smooth = (a: number, b: number, x: number) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* O aparelho abre um contexto WebGL2 de verdade, na GPU. Um WebGL por software (SwiftShader, llvmpipe, Microsoft
|
||||
* Basic Render) abre, mas desenha a cena a poucos quadros por segundo na CPU: nesse caso vale o hero estático.
|
||||
* O navegador tem a API do WebGL2? É só uma leitura: abrir um contexto de teste aqui, na hidratação, custava uma tarefa
|
||||
* de 150 ms (500 ms por software). Se o WebGL2 abre de verdade, e se ele desenha na GPU, quem diz é a própria cena,
|
||||
* no contexto que ela abre para desenhar (HeroFieldScene.createRenderer); o veredito "por software" fica anotado na
|
||||
* sessão (isKnownSoftwareWebGL), e as páginas seguintes já nascem estáticas.
|
||||
*/
|
||||
function supportsWebGL2() {
|
||||
if (typeof window.WebGL2RenderingContext === "undefined") return false;
|
||||
try {
|
||||
const context = document.createElement("canvas").getContext("webgl2");
|
||||
if (!context) return false;
|
||||
const software = isSoftwareWebGL(context);
|
||||
context.getExtension("WEBGL_lose_context")?.loseContext();
|
||||
return !software;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const hasWebGL2 = () => typeof window.WebGL2RenderingContext !== "undefined";
|
||||
|
||||
/** Um intervalo ocioso do navegador (sem requestIdleCallback, um instante depois). */
|
||||
function nextIdle() {
|
||||
@@ -183,7 +174,9 @@ function trackVisibility(
|
||||
* outros o hero é só o palco de 100svh com o fundo estático leve do CSS (.hero-glow), sem ouvinte de rolagem
|
||||
* e sem baixar o Three.js. No aparelho capaz, a cena WebGL entra na primeira interação ou quando a página fica
|
||||
* ociosa com folga, em passos separados (pacote, geometria, Canvas); a rolagem do hero conduz o mergulho
|
||||
* (trackDive). Sem legendas sobre o 3D: o espaço da direita é das molduras.
|
||||
* (trackDive). O desenho por software só se descobre no contexto que a cena abre (nunca num contexto de teste na
|
||||
* hidratação): ela avisa por `onError` e o hero volta ao palco estático. Sem legendas sobre o 3D: o espaço da direita
|
||||
* é das molduras.
|
||||
*/
|
||||
export default function HeroField() {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
@@ -202,9 +195,10 @@ export default function HeroField() {
|
||||
if (isLowPowerDevice()) return;
|
||||
const section = rootRef.current?.closest<HTMLElement>(".hero");
|
||||
if (!section) return;
|
||||
// WebGL2 só por software (ou nenhum): o hero estático de 100svh, já na hidratação, sem o bloco de rolagem do
|
||||
// mergulho que depois seria desfeito com a página já rolada
|
||||
if (!supportsWebGL2()) return;
|
||||
// sem WebGL2, ou com o desenho por software já descoberto nesta sessão: o hero estático de 100svh, já na hidratação,
|
||||
// sem o bloco de rolagem do mergulho. Num aparelho ainda não testado o mergulho liga já (o veredito sai do contexto
|
||||
// da cena, ocioso, sem uma tarefa longa na hidratação); sendo software, a cena avisa e o hero volta a 100svh
|
||||
if (!hasWebGL2() || isKnownSoftwareWebGL()) return;
|
||||
let alive = true;
|
||||
const stopDive = window.matchMedia?.(DIVE_QUERY).matches
|
||||
? trackDive(section, progressRef, setDone)
|
||||
|
||||
@@ -20,6 +20,8 @@ class MockRenderer {
|
||||
forceContextLoss = jest.fn();
|
||||
setSize = jest.fn();
|
||||
setPixelRatio = jest.fn();
|
||||
// um WebGL que não conta o nome do renderizador (sem WEBGL_debug_renderer_info): não é por software
|
||||
getContext = () => ({ getExtension: () => null });
|
||||
getPixelRatio = () => 1;
|
||||
constructor() {
|
||||
mockRenderers.push(this);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import {
|
||||
createField,
|
||||
type FieldEngine,
|
||||
needsFrame,
|
||||
isSettling,
|
||||
prepareField,
|
||||
type Rig,
|
||||
type Tick,
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
* - onReady no 2º quadro, amortecimento da rolagem (limite de dt, progresso ausente), entrada, escurecimento e abertura;
|
||||
* - retrato x paisagem (posição do grupo e distância da câmera) e o foco da lente;
|
||||
* - mouse ativo x parado x toque, raio que acerta ou não o plano, e a quantidade de pontos (denso x leve);
|
||||
* - quando pedir o próximo quadro (entrada, rolagem assentando, cursor) e quando não pedir;
|
||||
* - quando pedir o próximo quadro (entrada e rolagem assentando) e quando deixar para a batida de 30 por segundo (cursor);
|
||||
* - perda do contexto WebGL, falha ao desenhar e limpeza.
|
||||
* O WebGLRenderer é um dublê (o jsdom não tem WebGL); o `three` é o de verdade, com ShaderMaterial, BufferGeometry,
|
||||
* PerspectiveCamera e Group registrando as instâncias criadas. O quadro é chamado direto por `renderAt`, com o relógio
|
||||
@@ -48,6 +48,8 @@ class MockRenderer {
|
||||
forceContextLoss = jest.fn();
|
||||
setSize = jest.fn();
|
||||
setPixelRatio = jest.fn();
|
||||
// um WebGL que não conta o nome do renderizador (sem WEBGL_debug_renderer_info): não é por software
|
||||
getContext = () => ({ getExtension: () => null });
|
||||
getPixelRatio = () => this.pixelRatio;
|
||||
constructor() {
|
||||
mockRenderers.push(this);
|
||||
@@ -512,13 +514,16 @@ describe("createField: quando pedir o próximo quadro", () => {
|
||||
expect(scene.frame(50)).toBe(0);
|
||||
});
|
||||
|
||||
it("path: settled, visible, and the mouse is moving -> frames are requested", () => {
|
||||
it("path: settled, visible, and the mouse is moving -> the frame draws but asks for no other (the pulse brings the next at 30 Hz)", () => {
|
||||
const scene = setup({ visible: true });
|
||||
scene.settle(200);
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
const drawn = scene.renderer.render.mock.calls.length;
|
||||
|
||||
expect(scene.frame(50)).toBeGreaterThan(0);
|
||||
expect(scene.frame(50)).toBe(0);
|
||||
|
||||
expect(scene.renderer.render.mock.calls.length).toBe(drawn + 1);
|
||||
});
|
||||
|
||||
it("path: settled, mouse active, but the hero is not visible -> no frame (cursor only counts on screen)", () => {
|
||||
@@ -538,16 +543,17 @@ describe("createField: quando pedir o próximo quadro", () => {
|
||||
expect(scene.frame()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("path: becoming visible later -> the next frame sees the new flag", () => {
|
||||
it("path: becoming visible later -> the pulse starts and asks for a frame right away", () => {
|
||||
const scene = setup({ visible: false });
|
||||
scene.settle(200);
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
expect(scene.frame(50)).toBe(0);
|
||||
frameRequests.mockClear();
|
||||
|
||||
scene.engine.setVisible(true);
|
||||
|
||||
expect(scene.frame(50)).toBeGreaterThan(0);
|
||||
expect(frameRequests).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("path: several requests before a frame become ONE animation frame", () => {
|
||||
@@ -563,7 +569,7 @@ describe("createField: quando pedir o próximo quadro", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("needsFrame / updatePointer / updateRig (contas puras)", () => {
|
||||
describe("isSettling / updatePointer / updateRig (contas puras)", () => {
|
||||
function rig(): Rig {
|
||||
const scene = setup();
|
||||
scene.frame();
|
||||
@@ -604,23 +610,22 @@ describe("needsFrame / updatePointer / updateRig (contas puras)", () => {
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("needsFrame: settling wins over everything, interaction only counts on screen", () => {
|
||||
it("isSettling: the intro still running keeps asking, the settled scene does not", () => {
|
||||
const r = rig();
|
||||
r.uniforms.uIntro.value = 1;
|
||||
r.uniforms.uMouseAmt.value = 0.4;
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.4, false, true)).toBe(false);
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.4, true, true)).toBe(true);
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.4, true, false)).toBe(false);
|
||||
expect(needsFrame(r.uniforms, r.state, 0.5, 0.4, false, false)).toBe(true);
|
||||
expect(isSettling(r.uniforms, r.state, 0)).toBe(false);
|
||||
r.uniforms.uIntro.value = 0.5;
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.4, false, false)).toBe(true);
|
||||
expect(isSettling(r.uniforms, r.state, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("needsFrame: the first frames always ask for another (the cursor strength still has to settle)", () => {
|
||||
it("isSettling: the scroll target far from the shown progress keeps asking, the cursor does not (the pulse does that)", () => {
|
||||
const r = rig();
|
||||
r.uniforms.uIntro.value = 1;
|
||||
r.uniforms.uMouseAmt.value = 0;
|
||||
r.state.frames = 1;
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.4, false, true)).toBe(true);
|
||||
r.state.lastMove = 1;
|
||||
expect(isSettling(r.uniforms, r.state, 0)).toBe(false);
|
||||
expect(isSettling(r.uniforms, r.state, 0.5)).toBe(true);
|
||||
});
|
||||
|
||||
it("updatePointer: returns the strength it is heading for (0.8 with the mouse, 0.4 without, none in the dive)", () => {
|
||||
@@ -648,30 +653,12 @@ describe("needsFrame / updatePointer / updateRig (contas puras)", () => {
|
||||
expect(r.group.rotation.y).toBeCloseTo(0, 9);
|
||||
});
|
||||
|
||||
it("needsFrame: the cursor strength alone still far from its target asks for a frame, but only on screen", () => {
|
||||
it("isSettling: the scroll damping counts as settling only beyond 0.0004 of the target", () => {
|
||||
const r = rig();
|
||||
r.uniforms.uIntro.value = 1;
|
||||
r.uniforms.uMouseAmt.value = 0;
|
||||
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.8, false, true)).toBe(true);
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.8, false, false)).toBe(false);
|
||||
// dentro da tolerância de 0,01 já está assentado
|
||||
r.uniforms.uMouseAmt.value = 0.795;
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.8, false, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("needsFrame: the scroll damping counts as settling only beyond 0.0004 of the target", () => {
|
||||
const r = rig();
|
||||
r.uniforms.uIntro.value = 1;
|
||||
r.uniforms.uMouseAmt.value = 0.4;
|
||||
r.state.progress = 0.5;
|
||||
|
||||
expect(needsFrame(r.uniforms, r.state, 0.50039, 0.4, false, false)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(needsFrame(r.uniforms, r.state, 0.5005, 0.4, false, false)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isSettling(r.uniforms, r.state, 0.50039)).toBe(false);
|
||||
expect(isSettling(r.uniforms, r.state, 0.5005)).toBe(true);
|
||||
});
|
||||
|
||||
it("updateRig: the camera follows the choreography -> the first frame before it starts, a smoothed midpoint, the last frame after it ends", () => {
|
||||
@@ -856,12 +843,45 @@ describe("createField: o cursor sozinho não passa de ~30 quadros por segundo",
|
||||
return scene.renderer.render.mock.calls.length - before;
|
||||
}
|
||||
|
||||
function withMovingCursor() {
|
||||
/**
|
||||
* Uma tela de `refreshMs` entre quadros, com tudo assentado e o cursor mexendo: o único relógio é a batida (o
|
||||
* setInterval de 33 ms do FramePulse, aqui tocado à mão), e um quadro da tela só roda se alguém o pediu.
|
||||
*/
|
||||
function pulsedScreen(refreshMs: number) {
|
||||
let beat = () => {};
|
||||
jest.spyOn(window, "setInterval").mockImplementation(((
|
||||
callback: () => void,
|
||||
) => {
|
||||
beat = callback;
|
||||
return 7;
|
||||
}) as never);
|
||||
jest.spyOn(window, "clearInterval").mockImplementation(() => {});
|
||||
let pending: FrameRequestCallback | undefined;
|
||||
frameRequests.mockImplementation(((callback: FrameRequestCallback) => {
|
||||
pending = callback;
|
||||
return 1;
|
||||
}) as never);
|
||||
const scene = setup({ visible: true });
|
||||
scene.settle(200);
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
let clock = 0;
|
||||
let nextBeat = 33;
|
||||
const run = (seconds: number) => {
|
||||
const drawnBefore = scene.renderer.render.mock.calls.length;
|
||||
frameRequests.mockClear();
|
||||
const end = clock + seconds * 1000;
|
||||
for (clock += refreshMs; clock <= end; clock += refreshMs) {
|
||||
for (; nextBeat <= clock; nextBeat += 33) beat();
|
||||
const frame = pending;
|
||||
pending = undefined;
|
||||
frame?.(1000 + clock);
|
||||
}
|
||||
return {
|
||||
drawn: scene.renderer.render.mock.calls.length - drawnBefore,
|
||||
requested: frameRequests.mock.calls.length,
|
||||
};
|
||||
};
|
||||
run(6); // a entrada corre a cada quadro da tela e assenta
|
||||
pointerMove("mouse");
|
||||
return scene;
|
||||
return run;
|
||||
}
|
||||
|
||||
it.each([
|
||||
@@ -869,32 +889,25 @@ describe("createField: o cursor sozinho não passa de ~30 quadros por segundo",
|
||||
[120, 1000 / 120],
|
||||
[60, 1000 / 60],
|
||||
])(
|
||||
"path: a %i Hz screen with the cursor moving -> about 30 frames a second, not one per screen frame",
|
||||
"path: a %i Hz screen with the cursor moving -> about 30 frames a second drawn and asked for, never a wake-up per screen frame",
|
||||
(_hz, refreshMs) => {
|
||||
const scene = withMovingCursor();
|
||||
const run = pulsedScreen(refreshMs);
|
||||
|
||||
const drawn = drawnOn(scene, refreshMs, 2);
|
||||
const { drawn, requested } = run(2);
|
||||
|
||||
expect(drawn).toBeGreaterThanOrEqual(2 * 25);
|
||||
expect(drawn).toBeLessThanOrEqual(2 * 31);
|
||||
// quem pede é a batida: nenhum requestAnimationFrame para pular o desenho e pedir outro
|
||||
expect(requested).toBeLessThanOrEqual(drawn + 1);
|
||||
},
|
||||
);
|
||||
|
||||
it("path: a skipped frame draws nothing, keeps the clock of the last drawn one and asks for the next", () => {
|
||||
const scene = withMovingCursor();
|
||||
scene.frame(50); // o último desenhado
|
||||
const drawn = scene.renderer.render.mock.calls.length;
|
||||
|
||||
expect(scene.frame(10)).toBe(1); // 10 ms depois: pula e pede outro
|
||||
expect(scene.renderer.render.mock.calls.length).toBe(drawn);
|
||||
|
||||
expect(scene.frame(10)).toBe(1);
|
||||
expect(scene.frame(10)).toBe(1);
|
||||
expect(scene.renderer.render.mock.calls.length).toBe(drawn);
|
||||
|
||||
// 40 ms depois do último desenhado: sai (o relógio do quadro pulado não conta)
|
||||
scene.frame(10);
|
||||
expect(scene.renderer.render.mock.calls.length).toBe(drawn + 1);
|
||||
it("path: a frame the pulse asks for soon after another is drawn, not skipped (nothing throttles it any more)", () => {
|
||||
const run = pulsedScreen(1000 / 144);
|
||||
const drawn = run(0.5).drawn;
|
||||
expect(drawn).toBeGreaterThanOrEqual(14);
|
||||
// 27,8 ms (4 quadros de 144 Hz) entre duas batidas que se alinharam mal: o desenho sai do mesmo jeito
|
||||
expect(run(0.1).drawn).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("path: the intro and the scroll are not throttled (one frame per screen frame while they settle)", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { isKnownSoftwareWebGL } from "@/lib/device";
|
||||
import HeroFieldScene from "./HeroFieldScene";
|
||||
|
||||
/*
|
||||
@@ -15,6 +16,8 @@ type RendererOptions = {
|
||||
};
|
||||
const mockRenderers: MockRenderer[] = [];
|
||||
let mockFailToCreate = false;
|
||||
/** O nome do renderizador que o contexto de teste conta (WEBGL_debug_renderer_info); `null` = a extensão não existe. */
|
||||
let mockRendererName: string | null = null;
|
||||
/** Quando o teste quer segurar a compilação do shader: a promessa que `compileAsync` devolve. */
|
||||
let mockCompile: () => Promise<unknown> = () => Promise.resolve();
|
||||
|
||||
@@ -31,6 +34,14 @@ class MockRenderer {
|
||||
this.pixelRatio = value;
|
||||
};
|
||||
getPixelRatio = () => this.pixelRatio;
|
||||
getContext = () => ({
|
||||
getExtension: (name: string) =>
|
||||
name === "WEBGL_debug_renderer_info" && mockRendererName !== null
|
||||
? { UNMASKED_RENDERER_WEBGL: 37446 }
|
||||
: null,
|
||||
getParameter: (parameter: number) =>
|
||||
parameter === 37446 ? mockRendererName : null,
|
||||
});
|
||||
constructor(options: RendererOptions) {
|
||||
if (mockFailToCreate) throw new Error("sem WebGL");
|
||||
this.options = options;
|
||||
@@ -64,6 +75,7 @@ beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockRenderers.length = 0;
|
||||
mockFailToCreate = false;
|
||||
mockRendererName = null;
|
||||
mockCompile = () => Promise.resolve();
|
||||
setDevicePixelRatio(1);
|
||||
});
|
||||
@@ -406,3 +418,54 @@ describe("HeroFieldScene (ambient 3D)", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("HeroFieldScene: WebGL por software (o teste é o contexto da própria cena)", () => {
|
||||
beforeEach(() => sessionStorage.clear());
|
||||
afterEach(() => sessionStorage.clear());
|
||||
|
||||
it.each([
|
||||
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
|
||||
"llvmpipe (LLVM 15.0.7, 256 bits)",
|
||||
"ANGLE (Microsoft, Microsoft Basic Render Driver (0x0000008C) Direct3D11 vs_5_0 ps_5_0, D3D11)",
|
||||
])(
|
||||
"draws on the CPU (%s): no canvas, the page is told once, the context is freed and the session remembers it",
|
||||
async (name) => {
|
||||
mockRendererName = name;
|
||||
const scene = props();
|
||||
|
||||
const { container } = await mount(<HeroFieldScene {...scene} />);
|
||||
|
||||
// um só contexto: o do teste é o que a cena abriria para desenhar, sem um segundo
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
|
||||
expect(container.querySelector("canvas")).toBeNull();
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
expect(scene.onReady).not.toHaveBeenCalled();
|
||||
expect(isKnownSoftwareWebGL()).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("draws on the GPU: the canvas stays and the session is not marked", async () => {
|
||||
mockRendererName =
|
||||
"ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Direct3D11 vs_5_0 ps_5_0, D3D11)";
|
||||
const scene = props();
|
||||
|
||||
const { container } = await mount(<HeroFieldScene {...scene} />);
|
||||
|
||||
expect(container.querySelector("canvas")).toBe(mockRenderers[0].domElement);
|
||||
expect(scene.onError).not.toHaveBeenCalled();
|
||||
expect(isKnownSoftwareWebGL()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not report an unknown renderer (no debug extension) as software", async () => {
|
||||
mockRendererName = null;
|
||||
const scene = props();
|
||||
|
||||
await mount(<HeroFieldScene {...scene} />);
|
||||
|
||||
expect(scene.onError).not.toHaveBeenCalled();
|
||||
expect(isKnownSoftwareWebGL()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Vector3,
|
||||
WebGLRenderer,
|
||||
} from "three";
|
||||
import { isSoftwareWebGL, rememberSoftwareWebGL } from "@/lib/device";
|
||||
import { startFramePulse } from "./FramePulse";
|
||||
|
||||
export type HeroFieldSceneProps = {
|
||||
@@ -42,8 +43,8 @@ export type HeroFieldSceneProps = {
|
||||
* É um fundo ambiente: HeroField o deixa com pouca opacidade e uma máscara que o afasta do título e das
|
||||
* molduras da direita. O Three.js é usado direto (sem React Three Fiber, que levava junto o reconciliador do React
|
||||
* só para uma malha e um shader). O desenho é sob demanda: a cena pede um quadro a 30 por segundo (FramePulse, um
|
||||
* setInterval) e a 60 só enquanto algo muda (entrada, rolagem, cursor); sem estar à vista, ou depois de uns segundos
|
||||
* sem cursor nem rolagem, não desenha nada.
|
||||
* setInterval) e no ritmo da tela só enquanto a entrada ou a rolagem assenta; sem estar à vista, ou depois de uns
|
||||
* segundos sem cursor nem rolagem, não desenha nada.
|
||||
*/
|
||||
|
||||
type Layer = 1 | 0 | -1; // 1 = tela, 0 = integrações, -1 = dados
|
||||
@@ -62,15 +63,19 @@ class FieldBuilder {
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
style: PointStyle = {},
|
||||
style?: PointStyle,
|
||||
to?: [number, number, number],
|
||||
) {
|
||||
this.base.push(x, y, z);
|
||||
this.to.push(...(to ?? [x, y, z]));
|
||||
this.move.push(to ? 1 : 0);
|
||||
this.tone.push(style.tone ?? 0);
|
||||
this.size.push(style.size ?? 0.02);
|
||||
this.alpha.push(style.alpha ?? 0.7);
|
||||
this.pushStyle(style);
|
||||
}
|
||||
/** O tom, o tamanho e a opacidade do ponto, com o padrão onde o estilo não diz. */
|
||||
private pushStyle({ tone = 0, size = 0.02, alpha = 0.7 }: PointStyle = {}) {
|
||||
this.tone.push(tone);
|
||||
this.size.push(size);
|
||||
this.alpha.push(alpha);
|
||||
}
|
||||
line(
|
||||
y: Layer,
|
||||
@@ -607,31 +612,14 @@ export function isSettling(u: Uniforms, s: FieldState, target: number) {
|
||||
return u.uIntro.value < 1 || Math.abs(target - s.progress) > 4e-4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sob demanda: o próximo quadro vem do FramePulse (30 por segundo, e nenhum depois de uns segundos sem cursor nem
|
||||
* rolagem); o quadro pede o seguinte (no ritmo da tela, até 60) só enquanto algo ainda muda. A entrada e a rolagem
|
||||
* terminam de assentar mesmo depois de o hero sair de cena (o último quadro tem de ser o final); o cursor só conta
|
||||
* com o hero à vista.
|
||||
/*
|
||||
* Sob demanda, com um só relógio para o que é ambiente: o FramePulse (um setInterval de 30 por segundo, ligado só com o
|
||||
* hero à vista e parado depois de uns segundos sem cursor nem rolagem) pede o quadro do cursor e da onda dele; a tela,
|
||||
* a 60 ou 144 Hz, não pede nada por conta própria. Um quadro só pede o seguinte de imediato, no ritmo da tela, enquanto
|
||||
* a entrada ou a rolagem assenta (`isSettling`), mesmo com o hero fora de cena: o último quadro tem de ser o final.
|
||||
* Antes, o cursor também encadeava um requestAnimationFrame por quadro da tela (143 por segundo a 144 Hz) só para
|
||||
* pular o desenho e pedir de novo, e a página acordava sem desenhar nada.
|
||||
*/
|
||||
export function needsFrame(
|
||||
u: Uniforms,
|
||||
s: FieldState,
|
||||
target: number,
|
||||
amount: number,
|
||||
cursorActive: boolean,
|
||||
visible: boolean,
|
||||
) {
|
||||
const interacting =
|
||||
cursorActive || Math.abs(amount - u.uMouseAmt.value) > 0.01 || s.frames < 3;
|
||||
return isSettling(u, s, target) || (visible && interacting);
|
||||
}
|
||||
|
||||
/**
|
||||
* Intervalo mínimo (ms) entre dois quadros quando só o cursor os pede: uns 30 por segundo, mesmo numa tela de 144 Hz
|
||||
* (que desenhava 142). Fica um pouco abaixo de 33,3 (dois quadros de uma tela de 60 Hz) para a tela de 60 Hz manter
|
||||
* 30 e a batida (um setInterval de 33 ms, com a folga do temporizador) não perder quadros.
|
||||
*/
|
||||
const CURSOR_FRAME_MS = 32;
|
||||
|
||||
/** O material de pontos: o shader, a mistura aditiva e os uniforms de partida. */
|
||||
function createMaterial() {
|
||||
@@ -695,13 +683,23 @@ export type FieldEngine = {
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
/** O renderizador transparente, sem antialias, na GPU de alto desempenho, com o canvas dentro de `host`. */
|
||||
/**
|
||||
* O renderizador transparente, sem antialias, na GPU de alto desempenho, com o canvas dentro de `host`. Lança se o
|
||||
* contexto não abre ou se ele desenha por software (SwiftShader, llvmpipe, Basic Render): abre, mas a cena sairia a
|
||||
* poucos quadros por segundo na CPU, e o hero fica estático. O contexto que acabou de abrir é o do teste (nenhum outro
|
||||
* é criado só para isso) e o veredito fica anotado na sessão (`rememberSoftwareWebGL`).
|
||||
*/
|
||||
function createRenderer(host: HTMLElement) {
|
||||
const renderer = new WebGLRenderer({
|
||||
alpha: true,
|
||||
antialias: false,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
if (isSoftwareWebGL(renderer.getContext())) {
|
||||
rememberSoftwareWebGL();
|
||||
disposeRenderer(renderer);
|
||||
throw new Error("WebGL por software");
|
||||
}
|
||||
renderer.setPixelRatio(pixelRatio());
|
||||
const canvas = renderer.domElement;
|
||||
canvas.setAttribute("aria-hidden", "true");
|
||||
@@ -733,24 +731,24 @@ function buildWorld(): World {
|
||||
return { scene, camera, group, geometry, material };
|
||||
}
|
||||
|
||||
/** O que o quadro lê de fora: o progresso da rolagem, se o hero está à vista e o relógio. */
|
||||
/** O que o quadro lê de fora: o progresso da rolagem e o relógio. */
|
||||
type StepInput = {
|
||||
target: number;
|
||||
visible: boolean;
|
||||
time: number;
|
||||
delta: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Um quadro: rolagem amortecida, uniforms, câmera e cursor. Diz se foi o segundo quadro (a página pode tirar o fundo
|
||||
* estático) e se algo ainda muda (então o próximo quadro é pedido de imediato).
|
||||
* estático) e se a entrada ou a rolagem ainda assenta (então o próximo quadro é pedido de imediato; o do cursor, não:
|
||||
* vem da batida).
|
||||
*/
|
||||
function stepField(
|
||||
world: World,
|
||||
state: FieldState,
|
||||
helpers: Helpers,
|
||||
layout: Layout,
|
||||
{ target, visible, time, delta }: StepInput,
|
||||
{ target, time, delta }: StepInput,
|
||||
) {
|
||||
const dt = Math.min(delta, 1 / 20);
|
||||
const u = world.material.uniforms;
|
||||
@@ -769,11 +767,11 @@ function stepField(
|
||||
};
|
||||
const tick: Tick = { p, face, time, dt, cursorActive };
|
||||
updateRig(rig, tick);
|
||||
const amount = updatePointer(rig, tick);
|
||||
updatePointer(rig, tick);
|
||||
state.frames += 1;
|
||||
return {
|
||||
ready: state.frames === 2,
|
||||
again: needsFrame(u, state, target, amount, cursorActive, visible),
|
||||
again: isSettling(u, state, target),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -866,8 +864,9 @@ function disposeParts({ renderer, world }: Parts) {
|
||||
/**
|
||||
* A cena sem React, a partir das peças já criadas: liga o tamanho, o mouse e a perda do contexto, e desenha sob
|
||||
* demanda. `invalidate` pede um quadro no próximo requestAnimationFrame (vários pedidos viram um); cada quadro
|
||||
* atualiza os uniforms, a câmera e o cursor e pede o seguinte só se algo ainda muda. O primeiro quadro sai no
|
||||
* requestAnimationFrame que `resize` pede, numa tarefa própria.
|
||||
* atualiza os uniforms, a câmera e o cursor e pede o seguinte só se a entrada ou a rolagem ainda assenta (o resto vem
|
||||
* da batida de 30 quadros por segundo). O primeiro quadro sai no requestAnimationFrame que `resize` pede, numa
|
||||
* tarefa própria.
|
||||
*/
|
||||
function startField(
|
||||
host: HTMLElement,
|
||||
@@ -877,7 +876,6 @@ function startField(
|
||||
const { renderer, world, state, helpers } = parts;
|
||||
const started = performance.now();
|
||||
let layout = frameLayout(1, 1, world.camera.fov, renderer.getPixelRatio());
|
||||
let visible = false;
|
||||
let alive = true;
|
||||
let raf = 0;
|
||||
let last = 0;
|
||||
@@ -890,32 +888,25 @@ function startField(
|
||||
if (alive) onError();
|
||||
};
|
||||
|
||||
/** Um quadro: avança a cena, avisa a página, pede o seguinte se ainda assenta e desenha. */
|
||||
const draw = (now: number) => {
|
||||
const delta = last ? (now - last) / 1000 : 1 / 60;
|
||||
last = now;
|
||||
const step = stepField(world, state, helpers, layout, {
|
||||
target: progressRef.current ?? 0,
|
||||
time: (now - started) / 1000,
|
||||
delta,
|
||||
});
|
||||
if (step.ready) onReady();
|
||||
if (step.again) invalidate();
|
||||
renderer.render(world.scene, world.camera);
|
||||
};
|
||||
|
||||
function render(now: number) {
|
||||
raf = 0;
|
||||
if (!alive) return;
|
||||
const target = progressRef.current ?? 0;
|
||||
// só o cursor pede quadro (a entrada e a rolagem seguem a cada quadro): pula este se faz menos de ~32 ms do
|
||||
// último e pede o próximo, em vez de desenhar a cada quadro da tela
|
||||
if (
|
||||
last &&
|
||||
now - last < CURSOR_FRAME_MS &&
|
||||
!isSettling(world.material.uniforms, state, target)
|
||||
) {
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
const delta = last ? (now - last) / 1000 : 1 / 60;
|
||||
last = now;
|
||||
try {
|
||||
const step = stepField(world, state, helpers, layout, {
|
||||
target,
|
||||
visible,
|
||||
time: (now - started) / 1000,
|
||||
delta,
|
||||
});
|
||||
if (step.ready) onReady();
|
||||
if (step.again) invalidate();
|
||||
renderer.render(world.scene, world.camera);
|
||||
draw(now);
|
||||
} catch {
|
||||
fail();
|
||||
}
|
||||
@@ -941,7 +932,6 @@ function startField(
|
||||
return {
|
||||
renderAt: render,
|
||||
setVisible(next) {
|
||||
visible = next;
|
||||
if (next) stopPulse ??= startFramePulse(invalidate);
|
||||
else {
|
||||
stopPulse?.();
|
||||
@@ -958,7 +948,7 @@ function startField(
|
||||
};
|
||||
}
|
||||
|
||||
/** A cena sem React, de uma vez só: cria o renderizador dentro de `host` e começa a desenhar. Lança se o aparelho não cria um contexto WebGL. */
|
||||
/** A cena sem React, de uma vez só: cria o renderizador dentro de `host` e começa a desenhar. Lança se o aparelho não cria um contexto WebGL ou se ele desenha por software. */
|
||||
export function createField(
|
||||
host: HTMLElement,
|
||||
callbacks: FieldCallbacks,
|
||||
@@ -974,7 +964,7 @@ const nextTask = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
* era uma só tarefa de 90 a 250 ms): o renderizador (o contexto WebGL); a nuvem de pontos e o estado; a compilação do
|
||||
* shader (`compileAsync`, que o navegador faz fora da thread principal); e, por fim, o tamanho e o primeiro quadro
|
||||
* (que sai no requestAnimationFrame seguinte). Devolve `null` se `isAlive` virou falso no caminho; lança se o
|
||||
* aparelho não cria um contexto WebGL ou se o shader não compila.
|
||||
* aparelho não cria um contexto WebGL, se ele desenha por software ou se o shader não compila.
|
||||
*/
|
||||
export async function mountField(
|
||||
host: HTMLElement,
|
||||
|
||||
@@ -10,11 +10,13 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { isWebKit } from "@/lib/device";
|
||||
import {
|
||||
chapterFraction,
|
||||
chapterIndexAt,
|
||||
chapterIndexAtScroll,
|
||||
chapterScrollFraction,
|
||||
getScrubSource,
|
||||
getShowcaseAssets,
|
||||
videoFractionAtScroll,
|
||||
} from "@/lib/project-showcase";
|
||||
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
||||
import { PhoneFrame } from "./PhoneFrame";
|
||||
@@ -23,8 +25,9 @@ import { type ShowcaseItem, useShowcaseOpen } from "./ShowcaseProvider";
|
||||
import {
|
||||
CAPTION_OUT_MS,
|
||||
easeToward,
|
||||
localAtProgress,
|
||||
locateInReel,
|
||||
REEL_HOLD,
|
||||
reelHolds,
|
||||
reelOffset,
|
||||
videoProgress,
|
||||
} from "./showcase-math";
|
||||
@@ -69,9 +72,13 @@ function seekVideo(video: HTMLVideoElement | null, fraction: number) {
|
||||
video.currentTime = target;
|
||||
}
|
||||
|
||||
/** Alguns navegadores móveis só desenham o primeiro quadro de um vídeo que já tocou uma vez. */
|
||||
/**
|
||||
* O WebKit (Safari, iPhone, iPad) só desenha o primeiro quadro de um vídeo que já tocou uma vez. Só ele toca e pausa:
|
||||
* nos outros navegadores tocar faz o arquivo inteiro ser baixado (os 4 vídeos eram 3,2 MB por visita, contra 63 KB
|
||||
* quando só as buscas da rolagem pedem os trechos que o visitante vê).
|
||||
*/
|
||||
function prime(video: HTMLVideoElement) {
|
||||
if (video.dataset.primed) return;
|
||||
if (video.dataset.primed || !isWebKit()) return;
|
||||
video.dataset.primed = "true";
|
||||
video
|
||||
.play()
|
||||
@@ -118,11 +125,15 @@ const tickState = (tick: number, current: number) => {
|
||||
return tick === current ? "now" : "done";
|
||||
};
|
||||
|
||||
/** Progresso (0 a 1) de cada vídeo quando o projeto `index` está na fração `local` do trecho dele. */
|
||||
/**
|
||||
* Progresso da rolagem (0 a 1, sem as pausas) de cada projeto quando o projeto `index` está na fração `local` do trecho
|
||||
* dele. É da rolagem, não do vídeo: os capítulos têm rolagem igual (`videoFractionAtScroll` leva ao ponto do vídeo).
|
||||
*/
|
||||
function targetsAt(count: number, index: number, local: number) {
|
||||
const { head, tail } = reelHolds(index, count);
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
if (i < index) return 1;
|
||||
return i === index ? videoProgress(local) : 0;
|
||||
return i === index ? videoProgress(local, head, tail) : 0;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -233,12 +244,15 @@ export default function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
const next = easeToward(state.smooth[index] ?? 0, target, seconds);
|
||||
state.smooth[index] = next;
|
||||
if (Math.abs(next - target) > 0.0004) moving = true;
|
||||
seekVideo(desktopVideos.current[index], next);
|
||||
seekVideo(
|
||||
desktopVideos.current[index],
|
||||
videoFractionAtScroll(items[index].config, next),
|
||||
);
|
||||
bars.current[index]?.style.setProperty("--p", next.toFixed(4));
|
||||
});
|
||||
const current = items[state.active];
|
||||
if (current) {
|
||||
const index = chapterIndexAt(
|
||||
const index = chapterIndexAtScroll(
|
||||
current.config,
|
||||
state.smooth[state.active] ?? 0,
|
||||
);
|
||||
@@ -253,20 +267,26 @@ export default function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
);
|
||||
|
||||
// um vídeo que acabou de carregar precisa ir ao ponto em que a rolagem já está
|
||||
const syncVideo = useCallback((index: number) => {
|
||||
const state = live.current;
|
||||
seekVideo(
|
||||
desktopVideos.current[index],
|
||||
state.smooth[index] ?? state.target[index] ?? 0,
|
||||
);
|
||||
}, []);
|
||||
const syncVideo = useCallback(
|
||||
(index: number) => {
|
||||
const state = live.current;
|
||||
seekVideo(
|
||||
desktopVideos.current[index],
|
||||
videoFractionAtScroll(
|
||||
items[index].config,
|
||||
state.smooth[index] ?? state.target[index] ?? 0,
|
||||
),
|
||||
);
|
||||
},
|
||||
[items],
|
||||
);
|
||||
|
||||
/** Troca o projeto em cena: o capítulo vem do ponto em que o vídeo dele já está. */
|
||||
const changeActive = useCallback(
|
||||
(index: number) => {
|
||||
const state = live.current;
|
||||
state.active = index;
|
||||
state.chapter = chapterIndexAt(
|
||||
state.chapter = chapterIndexAtScroll(
|
||||
items[index].config,
|
||||
state.smooth[index] ?? 0,
|
||||
);
|
||||
@@ -332,12 +352,9 @@ export default function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
|
||||
const goTo = (index: number) => scrollToReel(index, 0);
|
||||
const goToChapter = (index: number, chapterIndex: number) => {
|
||||
const fraction = chapterFraction(
|
||||
items[index].config,
|
||||
chapterIndex,
|
||||
"desktop",
|
||||
);
|
||||
scrollToReel(index, REEL_HOLD + fraction * (1 - REEL_HOLD * 2));
|
||||
const { head, tail } = reelHolds(index, items.length);
|
||||
const progress = chapterScrollFraction(items[index].config, chapterIndex);
|
||||
scrollToReel(index, localAtProgress(progress, head, tail));
|
||||
};
|
||||
|
||||
const current = items[active];
|
||||
|
||||
@@ -15,12 +15,17 @@ jest.mock("next/link", () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
prefetch: _prefetch,
|
||||
prefetch,
|
||||
...props
|
||||
}: { children?: React.ReactNode; prefetch?: unknown } & Record<
|
||||
string,
|
||||
unknown
|
||||
>) => <a {...props}>{children}</a>,
|
||||
>) => (
|
||||
// o que o Link recebeu em `prefetch` aparece no atributo: false = nunca pré-carrega, null = o padrão do Next
|
||||
<a data-prefetch={String(prefetch)} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
const base = buildShowcaseItems(getFeaturedProjects(null)).find(
|
||||
@@ -73,6 +78,30 @@ describe("ProjectActions sem grouped (a lista)", () => {
|
||||
expect(container.querySelector("[tabindex]")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["without grouped", false],
|
||||
["grouped (the pinned reel)", true],
|
||||
])(
|
||||
"path: 'Ver case' %s -> prefetches only when the visitor arrives at it (hover, focus or touch)",
|
||||
(_name, grouped) => {
|
||||
render(
|
||||
<ProjectActions
|
||||
item={withSite}
|
||||
onExpand={jest.fn()}
|
||||
grouped={grouped}
|
||||
/>,
|
||||
);
|
||||
const anchor = link(/Ver case/);
|
||||
|
||||
// um por projeto, e na lista todos à vista: nenhum pedido de pré-carregamento na abertura da página
|
||||
expect(anchor).toHaveAttribute("data-prefetch", "false");
|
||||
|
||||
fireEvent.mouseEnter(anchor);
|
||||
|
||||
expect(anchor).toHaveAttribute("data-prefetch", "null");
|
||||
},
|
||||
);
|
||||
|
||||
it("path: no onExpand (before the page mounts) and no site -> only 'Ver case'", () => {
|
||||
render(<ProjectActions item={withoutSite} />);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
||||
import type { ShowcaseItem } from "./ShowcaseProvider";
|
||||
import { useRovingTabindex } from "./useRovingTabindex";
|
||||
|
||||
@@ -51,14 +51,14 @@ export function ProjectActions({
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
<Link
|
||||
<HoverPrefetchLink
|
||||
className="ds-link ds-link-muted"
|
||||
href={`/cases/${project.id}`}
|
||||
{...roving("case")}
|
||||
>
|
||||
Ver case
|
||||
<span className="sr-only"> de {project.title}</span>
|
||||
</Link>
|
||||
</HoverPrefetchLink>
|
||||
{onExpand && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -219,15 +219,18 @@ describe("ProjectLightbox", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps 0:00 / 0:00 until the metadata is there, then reads the duration from the playback tick", () => {
|
||||
it("shows –:– / –:– until the duration is there, then reads it from the playback tick", () => {
|
||||
const frames: FrameRequestCallback[] = [];
|
||||
jest.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||
frames.push(cb);
|
||||
return frames.length;
|
||||
});
|
||||
const dialog = setup();
|
||||
expect(dialog.querySelector(".ds-lb-time")?.textContent).toBe(
|
||||
"0:00 / 0:00",
|
||||
expect(dialog.querySelector(".ds-lb-time")?.textContent).toBe("–:– / –:–");
|
||||
// o leitor de tela também não ouve "0:00 de 0:00"
|
||||
expect(dialog.querySelector(".ds-lb-range")).toHaveAttribute(
|
||||
"aria-valuetext",
|
||||
"duração ainda não carregada",
|
||||
);
|
||||
|
||||
const video = dialog.querySelector("video") as HTMLVideoElement;
|
||||
|
||||
@@ -33,6 +33,15 @@ const formatTime = (seconds: number) => {
|
||||
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
/** A duração do vídeo já chegou? Antes dos metadados ela é 0 (ou NaN), e "0:00 / 0:00" parece um vídeo vazio. */
|
||||
const isKnown = (duration: number) => Number.isFinite(duration) && duration > 0;
|
||||
|
||||
/** "0:07 / 0:30"; "–:– / –:–" enquanto a duração não carrega. */
|
||||
const clock = (elapsed: number, duration: number) =>
|
||||
isKnown(duration)
|
||||
? `${formatTime(elapsed)} / ${formatTime(duration)}`
|
||||
: "–:– / –:–";
|
||||
|
||||
/**
|
||||
* Vídeo ampliado: janela modal nativa (<dialog>: foco preso, Esc fecha, foco volta ao botão que abriu),
|
||||
* com troca desktop/celular que mantém o ponto do site, capítulos clicáveis e controles próprios.
|
||||
@@ -353,7 +362,7 @@ export function ProjectLightbox({
|
||||
</svg>
|
||||
</button>
|
||||
<span className="ds-lb-time" aria-hidden="true">
|
||||
{formatTime(fraction * duration)} / {formatTime(duration)}
|
||||
{clock(fraction * duration, duration)}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
@@ -366,7 +375,11 @@ export function ProjectLightbox({
|
||||
seekTo(Number(event.target.value) / 1000)
|
||||
}
|
||||
aria-label="Posição do vídeo"
|
||||
aria-valuetext={`${formatTime(fraction * duration)} de ${formatTime(duration)}`}
|
||||
aria-valuetext={
|
||||
isKnown(duration)
|
||||
? `${formatTime(fraction * duration)} de ${formatTime(duration)}`
|
||||
: "duração ainda não carregada"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -215,6 +215,7 @@ afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
window.matchMedia = originalMatchMedia;
|
||||
Reflect.deleteProperty(navigator, "userAgent");
|
||||
Object.defineProperty(navigator, "connection", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
@@ -535,22 +536,61 @@ describe("ProjectReel: vídeos", () => {
|
||||
expect(videos()[0].currentTime).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("path: first frame decoded -> primed once (play then pause) even if loadeddata fires twice", async () => {
|
||||
it("path: Chrome (anything but WebKit) -> the video never plays: playing downloads the whole file, scrubbing only asks for what is seen", async () => {
|
||||
renderReel();
|
||||
const video = videos()[0];
|
||||
|
||||
fireEvent(video, new Event("loadeddata"));
|
||||
fireEvent(video, new Event("loadeddata"));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(HTMLMediaElement.prototype.play).toHaveBeenCalledTimes(1);
|
||||
expect(HTMLMediaElement.prototype.pause).toHaveBeenCalledTimes(1);
|
||||
expect(video.dataset.primed).toBe("true");
|
||||
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
|
||||
expect(HTMLMediaElement.prototype.pause).not.toHaveBeenCalled();
|
||||
expect(video.dataset.primed).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"iPhone (Safari)",
|
||||
"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",
|
||||
],
|
||||
[
|
||||
"iPhone (Chrome, also WebKit)",
|
||||
"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",
|
||||
],
|
||||
[
|
||||
"Safari on the Mac",
|
||||
"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",
|
||||
],
|
||||
])(
|
||||
"path: %s (WebKit) -> primed once (play then pause) even if loadeddata fires twice",
|
||||
async (_name, userAgent) => {
|
||||
Object.defineProperty(navigator, "userAgent", {
|
||||
configurable: true,
|
||||
value: userAgent,
|
||||
});
|
||||
renderReel();
|
||||
const video = videos()[0];
|
||||
|
||||
fireEvent(video, new Event("loadeddata"));
|
||||
fireEvent(video, new Event("loadeddata"));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(HTMLMediaElement.prototype.play).toHaveBeenCalledTimes(1);
|
||||
expect(HTMLMediaElement.prototype.pause).toHaveBeenCalledTimes(1);
|
||||
expect(video.dataset.primed).toBe("true");
|
||||
},
|
||||
);
|
||||
|
||||
it("path: priming is refused by the browser -> swallowed, no pause", async () => {
|
||||
Object.defineProperty(navigator, "userAgent", {
|
||||
configurable: true,
|
||||
value:
|
||||
"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",
|
||||
});
|
||||
jest
|
||||
.mocked(HTMLMediaElement.prototype.play)
|
||||
.mockRejectedValue(new DOMException("blocked", "NotAllowedError"));
|
||||
@@ -621,9 +661,9 @@ describe("ProjectReel: estado das camadas, traços de capítulo e legenda", () =
|
||||
|
||||
scrollTo(0);
|
||||
|
||||
expect(states(".ds-reel-ticks li")).toEqual(["now", "todo", "todo"]);
|
||||
expect(states(".ds-reel-ticks li")).toEqual(["now", "todo"]);
|
||||
expect(caption().querySelector(".ds-reel-chapter-count")).toHaveTextContent(
|
||||
"01/03",
|
||||
"01/02",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { MOBILE_QUERY } from "@/lib/device";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import { getShowcaseConfig } from "@/lib/project-showcase";
|
||||
import {
|
||||
getShowcaseConfig,
|
||||
videoFractionAtScroll,
|
||||
} from "@/lib/project-showcase";
|
||||
import { ProjectReel } from "./ProjectReel";
|
||||
import { ShowcaseProvider } from "./ShowcaseProvider";
|
||||
import { CAPTION_OUT_MS, reelOffset, videoProgress } from "./showcase-math";
|
||||
@@ -290,11 +291,18 @@ describe("ProjectReel", () => {
|
||||
const layer = activeLayer();
|
||||
expect(layer.querySelector(".ds-reel-title")).toBeNull();
|
||||
expect(caption()).toHaveTextContent("Lavação 047");
|
||||
const p = videoProgress(0.5);
|
||||
// a rolagem do projeto é repartida por igual entre os capítulos: o meio dela é onde o 2º capítulo começa no vídeo
|
||||
const lavacao = getShowcaseConfig("lavacao-047");
|
||||
if (!lavacao) throw new Error("projeto fora da vitrine");
|
||||
const p = videoFractionAtScroll(lavacao, videoProgress(0.5));
|
||||
const desktop = layer.querySelector(
|
||||
".ds-reel-screen video",
|
||||
) as HTMLVideoElement;
|
||||
expect(desktop.currentTime).toBeCloseTo(p * 30, 1);
|
||||
expect(p).toBeCloseTo(
|
||||
lavacao.chapters[1].desktop / lavacao.durations.desktop,
|
||||
9,
|
||||
);
|
||||
// o celular da moldura só tem o pôster: nenhum vídeo dentro dele
|
||||
expect(layer.querySelector(".ds-phone")).toBeInTheDocument();
|
||||
expect(layer.querySelector(".ds-phone video")).toBeNull();
|
||||
@@ -312,7 +320,7 @@ describe("ProjectReel", () => {
|
||||
expect(lavacao).toBeDefined();
|
||||
expect(
|
||||
caption().querySelector(".ds-reel-chapter-count")?.textContent,
|
||||
).toMatch(/^0\d\/03$/);
|
||||
).toMatch(/^0\d\/02$/);
|
||||
const labels = lavacao?.chapters.map((chapter) => chapter.label) ?? [];
|
||||
expect(labels).toContain(
|
||||
caption().querySelector(".ds-reel-chapter strong")?.textContent,
|
||||
@@ -512,16 +520,16 @@ describe("ProjectReel", () => {
|
||||
expect(list.querySelector("h2, h3")?.tagName).toBe("H2");
|
||||
});
|
||||
|
||||
it("limits each project to three chapters, in the index and in the caption", () => {
|
||||
it("limits each project to two chapters, in the index and in the caption", () => {
|
||||
renderReel();
|
||||
for (const layer of document.querySelectorAll(".ds-reel-layer"))
|
||||
expect(
|
||||
layer.querySelectorAll(".ds-reel-ticks li").length,
|
||||
).toBeLessThanOrEqual(3);
|
||||
).toBeLessThanOrEqual(2);
|
||||
const chapters = screen.getByRole("list", {
|
||||
name: "Trechos de SEV Moda Íntima",
|
||||
});
|
||||
expect(within(chapters).getAllByRole("button")).toHaveLength(3);
|
||||
expect(within(chapters).getAllByRole("button")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows the real domain in the address bar, or just the project name, never github.com", () => {
|
||||
@@ -586,7 +594,9 @@ describe("ProjectReel", () => {
|
||||
it("scrolls to a chapter inside the project when its link is pressed", () => {
|
||||
renderReel();
|
||||
const nav = screen.getByRole("navigation", { name: "Projetos da vitrine" });
|
||||
fireEvent.click(within(nav).getByRole("button", { name: /As favoritas/ }));
|
||||
fireEvent.click(
|
||||
within(nav).getByRole("button", { name: /Coleção completa/ }),
|
||||
);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1200);
|
||||
});
|
||||
@@ -595,6 +605,11 @@ describe("ProjectReel", () => {
|
||||
const last = calls[calls.length - 1][0] as { top: number };
|
||||
expect(last.top).toBeGreaterThan(reelOffset(weights, 0, 0) * SCROLLABLE);
|
||||
expect(last.top).toBeLessThan(reelOffset(weights, 1, 0) * SCROLLABLE);
|
||||
// o 2º capítulo começa na metade da rolagem do projeto (que vai de REEL_HOLD a 1 - REEL_HOLD do trecho dele)
|
||||
expect(last.top).toBeCloseTo(
|
||||
reelOffset(weights, 0, 0.5) * SCROLLABLE + 2,
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -729,10 +744,10 @@ describe("ProjectReel: a troca de projeto não duplica nada na tela", () => {
|
||||
expect(titles()).toEqual(["SEV Moda Íntima"]);
|
||||
expect(
|
||||
caption().querySelector(".ds-reel-chapter-count")?.textContent,
|
||||
).not.toBe("01/03");
|
||||
).not.toBe("01/02");
|
||||
});
|
||||
|
||||
it("never moves the caption text: no inline transform, and the CSS only fades it", () => {
|
||||
it("never moves the caption text: no inline transform, while it leaves and when it comes back with the new content", () => {
|
||||
renderReel();
|
||||
moveTo(1, 60);
|
||||
|
||||
@@ -740,17 +755,7 @@ describe("ProjectReel: a troca de projeto não duplica nada na tela", () => {
|
||||
for (const element of caption().querySelectorAll("*"))
|
||||
expect((element as HTMLElement).style.transform).toBe("");
|
||||
|
||||
const css = ["reel-list.css", "pinned-reel.css"]
|
||||
.map((file) => fs.readFileSync(path.join(__dirname, file), "utf8"))
|
||||
.join("\n");
|
||||
const rule = /\.ds-reel-caption \{[\s\S]*?\n\}/.exec(css)?.[0] ?? "";
|
||||
expect(rule).toContain("opacity");
|
||||
expect(rule).not.toMatch(/transform|translate/);
|
||||
const layer = /\.ds-reel-layer \{[\s\S]*?\n\}/.exec(css)?.[0] ?? "";
|
||||
expect(layer).toContain("opacity");
|
||||
expect(layer).not.toMatch(/transform|translate/);
|
||||
|
||||
// comportamento: a legenda sai (atributo, sem estilo inline) e volta com o conteúdo novo sem nunca se deslocar
|
||||
// a legenda sai (atributo, sem estilo inline) e volta com o conteúdo novo sem nunca se deslocar
|
||||
expect(caption()).toHaveAttribute("data-leaving");
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(CAPTION_OUT_MS + 40);
|
||||
@@ -777,8 +782,8 @@ describe("ProjectReel: teclado do índice (roving tabindex)", () => {
|
||||
it("makes the whole index ONE tab stop (the current project), however many projects and chapters it lists", () => {
|
||||
renderReel();
|
||||
const buttons = within(index()).getAllByRole("button");
|
||||
// 5 projetos e os 3 trechos do projeto em cena: 8 botões, uma parada
|
||||
expect(buttons).toHaveLength(5 + 3);
|
||||
// 5 projetos e os 2 trechos do projeto em cena: 7 botões, uma parada
|
||||
expect(buttons).toHaveLength(5 + 2);
|
||||
expect(stops()).toHaveLength(1);
|
||||
expect(stops()[0]).toHaveAttribute("aria-current", "true");
|
||||
expect(stops()[0]).toHaveAccessibleName("SEV Moda Íntima");
|
||||
|
||||
@@ -124,36 +124,4 @@ describe("carregamento sob demanda", () => {
|
||||
expect(mockLoads.pinned).toBe(1);
|
||||
expect(container.querySelector(".ds-reel-placeholder")).toBeNull();
|
||||
});
|
||||
|
||||
it("4. the page code never imports the heavy modules by name: only the *Lazy files do, with import()", () => {
|
||||
const fs = require("node:fs") as typeof import("node:fs");
|
||||
const path = require("node:path") as typeof import("node:path");
|
||||
const read = (file: string) =>
|
||||
fs.readFileSync(path.join(__dirname, file), "utf8");
|
||||
|
||||
expect(read("ShowcaseProvider.tsx")).not.toMatch(
|
||||
/from "\.\/ProjectLightbox"/,
|
||||
);
|
||||
expect(read("ProjectReel.tsx")).not.toMatch(/from "\.\/PinnedReel"/);
|
||||
expect(read("ProjectLightboxLazy.tsx")).toMatch(
|
||||
/import\("\.\/ProjectLightbox"\)/,
|
||||
);
|
||||
expect(read("PinnedReelLazy.tsx")).toMatch(/import\("\.\/PinnedReel"\)/);
|
||||
// e o CSS de cada um viaja com o componente dele, não com a página
|
||||
expect(read("PinnedReel.tsx")).toMatch(/import "\.\/pinned-reel\.css"/);
|
||||
expect(read("ProjectLightbox.tsx")).toMatch(/import "\.\/lightbox\.css"/);
|
||||
// só as linhas de import contam: o comentário do layout pode citar os arquivos pelo nome
|
||||
expect(
|
||||
fs.readFileSync(path.join(__dirname, "../../layout.tsx"), "utf8"),
|
||||
).not.toMatch(
|
||||
/^\s*import\s+["'][^"']*(pinned-reel|lightbox|reel-list)\.css["']/m,
|
||||
);
|
||||
// a lista de projetos e o casco da vitrine (reel-list.css) vêm dentro do global: a home não carrega uma terceira
|
||||
// folha bloqueante, e nenhum componente a importa
|
||||
expect(read("ProjectReel.tsx")).not.toMatch(/reel-list\.css/);
|
||||
expect(read("ProjectList.tsx")).not.toMatch(/reel-list\.css/);
|
||||
expect(
|
||||
fs.readFileSync(path.join(__dirname, "../../globals.css"), "utf8"),
|
||||
).toMatch(/@import "\.\/components\/showcase\/reel-list\.css"/);
|
||||
});
|
||||
});
|
||||
@@ -337,6 +337,38 @@ html.ds-lb-open {
|
||||
}
|
||||
}
|
||||
|
||||
/* tamanhos do degradê da fileira de etapas (celular): sem rolagem a fileira não anima e os dois ficam em 0 */
|
||||
@property --ds-fade-start {
|
||||
syntax: "<length>";
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
|
||||
@property --ds-fade-end {
|
||||
syntax: "<length>";
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
|
||||
/* o progresso é o da rolagem da própria fileira: sem rolar, só a borda direita esmaece; no meio, as duas; no fim,
|
||||
só a esquerda */
|
||||
@keyframes ds-lb-edges {
|
||||
0% {
|
||||
--ds-fade-start: 0px;
|
||||
--ds-fade-end: 28px;
|
||||
}
|
||||
10% {
|
||||
--ds-fade-start: 28px;
|
||||
}
|
||||
90% {
|
||||
--ds-fade-end: 28px;
|
||||
}
|
||||
100% {
|
||||
--ds-fade-start: 28px;
|
||||
--ds-fade-end: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.ds-lb {
|
||||
padding: 0;
|
||||
@@ -353,6 +385,19 @@ html.ds-lb-open {
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
padding-bottom: 2px;
|
||||
/* degradê na borda que ainda tem etapas escondidas: some sozinho quando a fileira cabe ou chega ao fim */
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
#000 var(--ds-fade-start),
|
||||
#000 calc(100% - var(--ds-fade-end)),
|
||||
transparent
|
||||
);
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
animation: ds-lb-edges linear both;
|
||||
animation-timeline: scroll(self inline);
|
||||
}
|
||||
}
|
||||
|
||||
.ds-lb-chapters::-webkit-scrollbar {
|
||||
|
||||
@@ -223,6 +223,8 @@
|
||||
.ds-reel-item {
|
||||
flex: 0 0 min(82%, 340px);
|
||||
scroll-snap-align: start;
|
||||
/* um deslize rápido para em cada cartão, em vez de pular projetos */
|
||||
scroll-snap-stop: always;
|
||||
padding: var(--sp-5) var(--sp-4);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import {
|
||||
easeToward,
|
||||
localAtProgress,
|
||||
locateInReel,
|
||||
REEL_HOLD,
|
||||
REEL_LAST_TAIL,
|
||||
reelHolds,
|
||||
reelStarts,
|
||||
videoProgress,
|
||||
} from "./showcase-math";
|
||||
@@ -41,6 +45,34 @@ describe("videoProgress", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("videoProgress with a shorter pause at the end", () => {
|
||||
it("lets the video run until the tail starts", () => {
|
||||
expect(videoProgress(0.9, 0.1, 0.03)).toBeCloseTo((0.9 - 0.1) / 0.87, 9);
|
||||
expect(videoProgress(0.97, 0.1, 0.03)).toBe(1);
|
||||
expect(videoProgress(1, 0.1, 0.03)).toBe(1);
|
||||
expect(videoProgress(0.1, 0.1, 0.03)).toBe(0);
|
||||
});
|
||||
|
||||
it("localAtProgress is its inverse inside the moving part", () => {
|
||||
for (const progress of [0, 0.25, 0.5, 1]) {
|
||||
const local = localAtProgress(progress, 0.1, 0.03);
|
||||
expect(videoProgress(local, 0.1, 0.03)).toBeCloseTo(progress, 9);
|
||||
}
|
||||
expect(localAtProgress(0)).toBe(REEL_HOLD);
|
||||
expect(localAtProgress(1)).toBeCloseTo(1 - REEL_HOLD, 9);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reelHolds", () => {
|
||||
it("keeps the full pause everywhere but at the end of the last project, which has nothing to wait for", () => {
|
||||
expect(reelHolds(0, 4)).toEqual({ head: REEL_HOLD, tail: REEL_HOLD });
|
||||
expect(reelHolds(2, 4)).toEqual({ head: REEL_HOLD, tail: REEL_HOLD });
|
||||
expect(reelHolds(3, 4)).toEqual({ head: REEL_HOLD, tail: REEL_LAST_TAIL });
|
||||
expect(REEL_LAST_TAIL).toBeLessThan(REEL_HOLD);
|
||||
expect(reelHolds(0, 1).tail).toBe(REEL_LAST_TAIL);
|
||||
});
|
||||
});
|
||||
|
||||
describe("easeToward", () => {
|
||||
it("moves toward the target and never overshoots", () => {
|
||||
const first = easeToward(0, 1, 1 / 60);
|
||||
|
||||
@@ -38,6 +38,20 @@ export function reelStarts(weights: number[]): number[] {
|
||||
/** Fração do trecho de cada projeto em que o vídeo fica parado, no começo e no fim. */
|
||||
export const REEL_HOLD = 0.1;
|
||||
|
||||
/**
|
||||
* Pausa no fim do último projeto: depois dele não há troca a esperar, e o palco já se solta da página. Fica só o
|
||||
* bastante para o último quadro chegar antes de o palco subir (era a mesma pausa de 10%, com a rolagem ainda presa).
|
||||
*/
|
||||
export const REEL_LAST_TAIL = 0.03;
|
||||
|
||||
/** As pausas do projeto `index` de `count`: no começo e no fim do trecho dele. */
|
||||
export function reelHolds(index: number, count: number) {
|
||||
return {
|
||||
head: REEL_HOLD,
|
||||
tail: index === count - 1 ? REEL_LAST_TAIL : REEL_HOLD,
|
||||
};
|
||||
}
|
||||
|
||||
/** Avanço total (0 a 1) em que o projeto `index` está em `local` (0 a 1) do trecho dele. */
|
||||
export function reelOffset(weights: number[], index: number, local: number) {
|
||||
const total = weights.reduce((sum, weight) => sum + weight, 0);
|
||||
@@ -49,11 +63,24 @@ export function reelOffset(weights: number[], index: number, local: number) {
|
||||
* O trecho de cada projeto tem uma pausa no começo e outra no fim: o vídeo só anda no meio.
|
||||
* Isso dá tempo de ler o título antes de o site começar a rolar e de descansar antes da troca.
|
||||
*/
|
||||
export function videoProgress(local: number, hold = REEL_HOLD): number {
|
||||
const span = 1 - hold * 2;
|
||||
export function videoProgress(
|
||||
local: number,
|
||||
hold = REEL_HOLD,
|
||||
tail = hold,
|
||||
): number {
|
||||
const span = 1 - hold - tail;
|
||||
return Math.min(1, Math.max(0, (local - hold) / span));
|
||||
}
|
||||
|
||||
/** O contrário de `videoProgress`: onde, no trecho do projeto (0 a 1), o avanço `progress` do vídeo acontece. */
|
||||
export function localAtProgress(
|
||||
progress: number,
|
||||
hold = REEL_HOLD,
|
||||
tail = hold,
|
||||
) {
|
||||
return hold + progress * (1 - hold - tail);
|
||||
}
|
||||
|
||||
/** Aproxima `current` de `target` de forma suave, independente da taxa de quadros (`tau` em segundos). */
|
||||
export function easeToward(
|
||||
current: number,
|
||||
|
||||
@@ -781,6 +781,12 @@ ol.ds-list {
|
||||
}
|
||||
}
|
||||
|
||||
/* "Role para mergulhar" só vale com o mergulho: HeroField marca o hero com data-dive quando a cena 3D conduz a
|
||||
rolagem. No hero estático (toque, aparelho fraco, WebGL por software, movimento reduzido) a dica some. */
|
||||
.hero:not([data-dive]) .hero-cue {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-cue-line {
|
||||
width: 56px;
|
||||
height: 1px;
|
||||
@@ -1019,6 +1025,8 @@ html:active-view-transition body {
|
||||
|
||||
::view-transition-group(site-header) {
|
||||
animation: none;
|
||||
/* as imagens "page" (a que sai e a que entra) cobrem a faixa do cabeçalho: sem isto ele sumia durante a troca */
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { setNavigation } from "@/__mocks__/next/navigation";
|
||||
import { resetFocusOnNavigation } from "./hooks/useFocusOnNavigation";
|
||||
import Template from "./template";
|
||||
|
||||
let mockPathname = "/cases";
|
||||
jest.mock("next/navigation", () => ({ usePathname: () => mockPathname }));
|
||||
|
||||
const mockMounts = jest.fn();
|
||||
const mockProps: Array<Record<string, unknown>> = [];
|
||||
|
||||
@@ -34,7 +32,7 @@ function Counter() {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockPathname = "/cases";
|
||||
setNavigation({ pathname: "/cases" });
|
||||
mockMounts.mockClear();
|
||||
mockProps.length = 0;
|
||||
resetFocusOnNavigation();
|
||||
@@ -70,7 +68,7 @@ describe("app/template", () => {
|
||||
expect(mockMounts).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId("state")).toHaveTextContent("1");
|
||||
|
||||
mockPathname = "/cases/sev-modaintima";
|
||||
setNavigation({ pathname: "/cases/sev-modaintima" });
|
||||
rerender(
|
||||
<Template>
|
||||
<Counter />
|
||||
@@ -108,7 +106,7 @@ describe("app/template", () => {
|
||||
expect(focus).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("main")).not.toHaveFocus();
|
||||
|
||||
mockPathname = "/cases/sev-modaintima";
|
||||
setNavigation({ pathname: "/cases/sev-modaintima" });
|
||||
rerender(
|
||||
<Shell>
|
||||
<button type="button">Dentro da página</button>
|
||||
@@ -147,7 +145,7 @@ describe("app/template", () => {
|
||||
<p>Página</p>
|
||||
</Shell>,
|
||||
);
|
||||
mockPathname = "/servicos";
|
||||
setNavigation({ pathname: "/servicos" });
|
||||
rerender(
|
||||
<Shell>
|
||||
<p>Página</p>
|
||||
|
||||
@@ -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