diff --git a/app/components/cinematic/CinematicOverlays.paths.test.tsx b/app/components/cinematic/CinematicOverlays.paths.test.tsx
index 4be94b2..b9c5568 100644
--- a/app/components/cinematic/CinematicOverlays.paths.test.tsx
+++ b/app/components/cinematic/CinematicOverlays.paths.test.tsx
@@ -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();
await loaded();
expect(container).toBeEmptyDOMElement();
- mockPathname = "/";
+ setNavigation({ pathname: "/" });
rerender();
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();
expect(screen.queryByTestId("grain")).toBeNull();
expect(container).toBeEmptyDOMElement();
- mockPathname = "/";
+ setNavigation({ pathname: "/" });
rerender();
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();
await loaded();
diff --git a/app/components/cinematic/CinematicOverlays.test.tsx b/app/components/cinematic/CinematicOverlays.test.tsx
index 80bca8f..f51cf44 100644
--- a/app/components/cinematic/CinematicOverlays.test.tsx
+++ b/app/components/cinematic/CinematicOverlays.test.tsx
@@ -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();
await loaded();
diff --git a/app/components/cinematic/FramePulse.ts b/app/components/cinematic/FramePulse.ts
index 61240b9..adafb07 100644
--- a/app/components/cinematic/FramePulse.ts
+++ b/app/components/cinematic/FramePulse.ts
@@ -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,
diff --git a/app/components/cinematic/HeroField.paths.test.tsx b/app/components/cinematic/HeroField.paths.test.tsx
index f93847d..c1285a2 100644
--- a/app/components/cinematic/HeroField.paths.test.tsx
+++ b/app/components/cinematic/HeroField.paths.test.tsx
@@ -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();
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();
- 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();
- 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();
+ 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();
- act(() => {
- window.dispatchEvent(new Event("pointermove"));
- });
- await advance(3000);
- await finishLoading();
+ render();
+ 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();
+ 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();
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();
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();
- await advance(1200);
- await finishLoading();
-
- expect(screen.getByTestId("scene")).toBeInTheDocument();
+ expect(section()).not.toHaveAttribute("data-dive");
+ expect(screen.queryByTestId("scene")).toBeNull();
});
it.each([
diff --git a/app/components/cinematic/HeroField.test.tsx b/app/components/cinematic/HeroField.test.tsx
index a68e8b9..0eb6b33 100644
--- a/app/components/cinematic/HeroField.test.tsx
+++ b/app/components/cinematic/HeroField.test.tsx
@@ -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) {
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).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();
+ 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();
@@ -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) => {
diff --git a/app/components/cinematic/HeroField.tsx b/app/components/cinematic/HeroField.tsx
index 70b6ef9..0b9409a 100644
--- a/app/components/cinematic/HeroField.tsx
+++ b/app/components/cinematic/HeroField.tsx
@@ -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(null);
@@ -202,9 +195,10 @@ export default function HeroField() {
if (isLowPowerDevice()) return;
const section = rootRef.current?.closest(".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)
diff --git a/app/components/cinematic/HeroFieldScene.mount.paths.test.tsx b/app/components/cinematic/HeroFieldScene.mount.paths.test.tsx
index cf339c2..6b25c0b 100644
--- a/app/components/cinematic/HeroFieldScene.mount.paths.test.tsx
+++ b/app/components/cinematic/HeroFieldScene.mount.paths.test.tsx
@@ -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);
diff --git a/app/components/cinematic/HeroFieldScene.paths.test.tsx b/app/components/cinematic/HeroFieldScene.paths.test.tsx
index 317b6a2..126d11d 100644
--- a/app/components/cinematic/HeroFieldScene.paths.test.tsx
+++ b/app/components/cinematic/HeroFieldScene.paths.test.tsx
@@ -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)", () => {
diff --git a/app/components/cinematic/HeroFieldScene.test.tsx b/app/components/cinematic/HeroFieldScene.test.tsx
index 1fcd5c1..5bd9422 100644
--- a/app/components/cinematic/HeroFieldScene.test.tsx
+++ b/app/components/cinematic/HeroFieldScene.test.tsx
@@ -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 = () => 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();
+
+ // 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();
+
+ 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();
+
+ expect(scene.onError).not.toHaveBeenCalled();
+ expect(isKnownSoftwareWebGL()).toBe(false);
+ });
+});
diff --git a/app/components/cinematic/HeroFieldScene.tsx b/app/components/cinematic/HeroFieldScene.tsx
index 14feab4..bc98fa4 100644
--- a/app/components/cinematic/HeroFieldScene.tsx
+++ b/app/components/cinematic/HeroFieldScene.tsx
@@ -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((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,
diff --git a/app/components/showcase/PinnedReel.tsx b/app/components/showcase/PinnedReel.tsx
index 1ac952d..a738403 100644
--- a/app/components/showcase/PinnedReel.tsx
+++ b/app/components/showcase/PinnedReel.tsx
@@ -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];
diff --git a/app/components/showcase/ProjectActions.paths.test.tsx b/app/components/showcase/ProjectActions.paths.test.tsx
index c19565f..2ae9853 100644
--- a/app/components/showcase/ProjectActions.paths.test.tsx
+++ b/app/components/showcase/ProjectActions.paths.test.tsx
@@ -15,12 +15,17 @@ jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
- prefetch: _prefetch,
+ prefetch,
...props
}: { children?: React.ReactNode; prefetch?: unknown } & Record<
string,
unknown
- >) => {children},
+ >) => (
+ // o que o Link recebeu em `prefetch` aparece no atributo: false = nunca pré-carrega, null = o padrão do Next
+
+ {children}
+
+ ),
}));
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(
+ ,
+ );
+ 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();
diff --git a/app/components/showcase/ProjectActions.tsx b/app/components/showcase/ProjectActions.tsx
index 49322cf..c571689 100644
--- a/app/components/showcase/ProjectActions.tsx
+++ b/app/components/showcase/ProjectActions.tsx
@@ -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({
}
: {})}
>
-
Ver case
de {project.title}
-
+
{onExpand && (
- {formatTime(fraction * duration)} / {formatTime(duration)}
+ {clock(fraction * duration, duration)}
diff --git a/app/components/showcase/ProjectReel.paths.test.tsx b/app/components/showcase/ProjectReel.paths.test.tsx
index c70a57e..983226e 100644
--- a/app/components/showcase/ProjectReel.paths.test.tsx
+++ b/app/components/showcase/ProjectReel.paths.test.tsx
@@ -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",
);
});
diff --git a/app/components/showcase/ProjectReel.test.tsx b/app/components/showcase/ProjectReel.test.tsx
index 25282c1..1bcf1fc 100644
--- a/app/components/showcase/ProjectReel.test.tsx
+++ b/app/components/showcase/ProjectReel.test.tsx
@@ -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");
diff --git a/app/components/showcase/lazy-loading.test.tsx b/app/components/showcase/lazy-loading.test.tsx
index df024e5..d7a31db 100644
--- a/app/components/showcase/lazy-loading.test.tsx
+++ b/app/components/showcase/lazy-loading.test.tsx
@@ -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"/);
- });
});
diff --git a/app/components/showcase/lightbox.css b/app/components/showcase/lightbox.css
index 78370e2..33b690c 100644
--- a/app/components/showcase/lightbox.css
+++ b/app/components/showcase/lightbox.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: "";
+ inherits: false;
+ initial-value: 0px;
+}
+
+@property --ds-fade-end {
+ syntax: "";
+ 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 {
diff --git a/app/components/showcase/reel-list.css b/app/components/showcase/reel-list.css
index 4c697e4..a7d92d2 100644
--- a/app/components/showcase/reel-list.css
+++ b/app/components/showcase/reel-list.css
@@ -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);
diff --git a/app/components/showcase/showcase-math.test.ts b/app/components/showcase/showcase-math.test.ts
index cd0a9c4..c82c67e 100644
--- a/app/components/showcase/showcase-math.test.ts
+++ b/app/components/showcase/showcase-math.test.ts
@@ -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);
diff --git a/app/components/showcase/showcase-math.ts b/app/components/showcase/showcase-math.ts
index 178c64f..13bc97d 100644
--- a/app/components/showcase/showcase-math.ts
+++ b/app/components/showcase/showcase-math.ts
@@ -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,
diff --git a/app/globals.css b/app/globals.css
index 31b2f7e..bf178de 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -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 */
diff --git a/app/template.test.tsx b/app/template.test.tsx
index 288de92..680a1f5 100644
--- a/app/template.test.tsx
+++ b/app/template.test.tsx
@@ -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> = [];
@@ -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(
@@ -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(
@@ -147,7 +145,7 @@ describe("app/template", () => {
diff --git a/lib/device.test.ts b/lib/device.test.ts
index 179a127..74e9425 100644
--- a/lib/device.test.ts
+++ b/lib/device.test.ts
@@ -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);
+ });
+});
diff --git a/lib/device.ts b/lib/device.ts
index 3b47640..7f3a3bd 100644
--- a/lib/device.ts
+++ b/lib/device.ts
@@ -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;
+}
diff --git a/lib/project-showcase.test.ts b/lib/project-showcase.test.ts
index 5c30ea4..cfbd51d 100644
--- a/lib/project-showcase.test.ts
+++ b/lib/project-showcase.test.ts
@@ -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(
diff --git a/lib/project-showcase.ts b/lib/project-showcase.ts
index 3432540..df94965 100644
--- a/lib/project-showcase.ts
+++ b/lib/project-showcase.ts
@@ -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;