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

+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,