perf(movimento): vitrine e 3D mais leves, WebGL por software lembrado na sessão e suporte ao WebKit

This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 22:54:27 -03:00
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();
+3 -2
View File
@@ -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([
+21 -23
View File
@@ -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) => {
+13 -19
View File
@@ -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);
});
});
+55 -65
View File
@@ -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,
+40 -23
View File
@@ -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} />);
+3 -3
View File
@@ -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;
+15 -2
View File
@@ -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",
);
});
+29 -24
View File
@@ -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"/);
});
});
+45
View File
@@ -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 {
+2
View File
@@ -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);
+29 -2
View File
@@ -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,