perf(movimento): uma transição, 3D leve e vídeo certo por aparelho
Passagens com fade e 32 px, cena anterior termina em opacidade 0, sem visibility nem clip-path. 3D do hero como fundo ambiente, sob demanda e desligado em toque, aparelho fraco, saveData e movimento reduzido. Lenis fora do toque e salto direto em âncoras distantes. Vitrine só de clientes, até 3 capítulos, fixa só no desktop e carrossel no celular; vídeo só do projeto em cena com preload metadata. Rótulos mono a partir de 12 px e limão só no contato.
This commit is contained in:
1 parent
8d07b8c6bb
commit
c9a0b80656
48 files changed
+7210
-1241
No files matched your search
@@ -0,0 +1,185 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import ts from "typescript";
|
||||
import {
|
||||
isCoarsePointer,
|
||||
isLowPowerDevice,
|
||||
isMobileViewport,
|
||||
isSaveData,
|
||||
prefersReducedMotion,
|
||||
} from "./device";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de lib/device.ts (complemento de device.test.ts): matches() sem matchMedia,
|
||||
* cada condição de isLowPowerDevice isolada (inclusive os limites), isSaveData e o ambiente sem window/navigator
|
||||
* (servidor). O servidor é simulado transpilando o fonte e executando-o com `window` e `navigator` indefinidos,
|
||||
* porque o jsdom não deixa esconder esses globais.
|
||||
*/
|
||||
|
||||
type DeviceModule = typeof import("./device");
|
||||
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
|
||||
function mockMedia(active: string[]) {
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: active.includes(query),
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
}
|
||||
|
||||
function mockNavigator(values: Record<string, unknown>) {
|
||||
for (const [key, value] of Object.entries(values))
|
||||
Object.defineProperty(navigator, key, { configurable: true, value });
|
||||
}
|
||||
|
||||
function loadOnServer(globals: { window?: unknown; navigator?: unknown }) {
|
||||
const source = readFileSync(join(process.cwd(), "lib/device.ts"), "utf8");
|
||||
const { outputText } = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
});
|
||||
const loaded = { exports: {} as DeviceModule };
|
||||
new Function("module", "exports", "window", "navigator", outputText)(
|
||||
loaded,
|
||||
loaded.exports,
|
||||
globals.window,
|
||||
globals.navigator,
|
||||
);
|
||||
return loaded.exports;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockMedia([]);
|
||||
mockNavigator({
|
||||
hardwareConcurrency: 8,
|
||||
deviceMemory: undefined,
|
||||
connection: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
});
|
||||
|
||||
describe("matches() (through the media helpers)", () => {
|
||||
it("path: matchMedia is not available -> every media hint is false", () => {
|
||||
// @ts-expect-error simula um navegador sem matchMedia
|
||||
window.matchMedia = undefined;
|
||||
|
||||
expect(isCoarsePointer()).toBe(false);
|
||||
expect(prefersReducedMotion()).toBe(false);
|
||||
expect(isMobileViewport()).toBe(false);
|
||||
expect(isLowPowerDevice()).toBe(false);
|
||||
});
|
||||
|
||||
it("path: matchMedia answers false for the query -> false", () => {
|
||||
expect(isCoarsePointer()).toBe(false);
|
||||
});
|
||||
|
||||
it("path: matchMedia answers true only for the asked query -> only that hint is true", () => {
|
||||
mockMedia(["(prefers-reduced-motion: reduce)"]);
|
||||
|
||||
expect(prefersReducedMotion()).toBe(true);
|
||||
expect(isCoarsePointer()).toBe(false);
|
||||
expect(isMobileViewport()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSaveData()", () => {
|
||||
it.each([
|
||||
["connection is missing", undefined],
|
||||
["connection has no saveData", {}],
|
||||
["saveData is false", { saveData: false }],
|
||||
["saveData is a truthy non-boolean", { saveData: "true" }],
|
||||
])("path: %s -> false", (_label, connection) => {
|
||||
mockNavigator({ connection });
|
||||
|
||||
expect(isSaveData()).toBe(false);
|
||||
});
|
||||
|
||||
it("path: saveData is exactly true -> true", () => {
|
||||
mockNavigator({ connection: { saveData: true } });
|
||||
|
||||
expect(isSaveData()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLowPowerDevice(): limites de cada condição, uma de cada vez", () => {
|
||||
it.each([
|
||||
["5 cores", { hardwareConcurrency: 5 }],
|
||||
["8 GB of memory", { hardwareConcurrency: 8, deviceMemory: 8 }],
|
||||
["5 GB of memory", { hardwareConcurrency: 8, deviceMemory: 5 }],
|
||||
["cores reported as text", { hardwareConcurrency: "2" }],
|
||||
["memory reported as text", { deviceMemory: "2" }],
|
||||
["cores reported as null", { hardwareConcurrency: null }],
|
||||
])(
|
||||
"path: %s -> full power (just above the limit or not a number)",
|
||||
(_label, hints) => {
|
||||
mockNavigator(hints);
|
||||
|
||||
expect(isLowPowerDevice()).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["exactly 4 cores", { hardwareConcurrency: 4 }],
|
||||
["exactly 4 GB", { deviceMemory: 4 }],
|
||||
["0.5 GB", { deviceMemory: 0.5 }],
|
||||
["1 core", { hardwareConcurrency: 1 }],
|
||||
])("path: %s -> low power", (_label, hints) => {
|
||||
mockNavigator(hints);
|
||||
|
||||
expect(isLowPowerDevice()).toBe(true);
|
||||
});
|
||||
|
||||
it("path: only the first condition is true (touch) -> low power without reading the rest", () => {
|
||||
mockMedia(["(pointer: coarse)"]);
|
||||
mockNavigator({ hardwareConcurrency: 16, deviceMemory: 16 });
|
||||
|
||||
expect(isLowPowerDevice()).toBe(true);
|
||||
});
|
||||
|
||||
it("path: several conditions true at once -> still just true", () => {
|
||||
mockMedia(["(pointer: coarse)", "(prefers-reduced-motion: reduce)"]);
|
||||
mockNavigator({
|
||||
hardwareConcurrency: 2,
|
||||
deviceMemory: 2,
|
||||
connection: { saveData: true },
|
||||
});
|
||||
|
||||
expect(isLowPowerDevice()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("without window and navigator (server)", () => {
|
||||
it("path: no window -> media hints are false", () => {
|
||||
const device = loadOnServer({ window: undefined, navigator: undefined });
|
||||
|
||||
expect(device.isCoarsePointer()).toBe(false);
|
||||
expect(device.prefersReducedMotion()).toBe(false);
|
||||
expect(device.isMobileViewport()).toBe(false);
|
||||
});
|
||||
|
||||
it("path: no navigator -> save data is off and the device counts as low power", () => {
|
||||
const device = loadOnServer({ window: undefined, navigator: undefined });
|
||||
|
||||
expect(device.isSaveData()).toBe(false);
|
||||
expect(device.isLowPowerDevice()).toBe(true);
|
||||
});
|
||||
|
||||
it("path: a navigator without window (runtime that exposes only navigator) -> hints still read", () => {
|
||||
const device = loadOnServer({
|
||||
window: undefined,
|
||||
navigator: { hardwareConcurrency: 16, connection: { saveData: true } },
|
||||
});
|
||||
|
||||
expect(device.isSaveData()).toBe(true);
|
||||
expect(device.isLowPowerDevice()).toBe(true);
|
||||
const capable = loadOnServer({
|
||||
window: undefined,
|
||||
navigator: { hardwareConcurrency: 16 },
|
||||
});
|
||||
expect(capable.isLowPowerDevice()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
isCoarsePointer,
|
||||
isLowPowerDevice,
|
||||
isMobileViewport,
|
||||
isSaveData,
|
||||
MOBILE_QUERY,
|
||||
} from "./device";
|
||||
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
|
||||
function mockMedia(active: string[]) {
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: active.includes(query),
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
}
|
||||
|
||||
function mockNavigator(values: Record<string, unknown>) {
|
||||
for (const [key, value] of Object.entries(values))
|
||||
Object.defineProperty(navigator, key, { configurable: true, value });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockMedia([]);
|
||||
mockNavigator({
|
||||
hardwareConcurrency: 8,
|
||||
deviceMemory: undefined,
|
||||
connection: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
});
|
||||
|
||||
describe("device hints", () => {
|
||||
it("treats a capable desktop as full power", () => {
|
||||
expect(isLowPowerDevice()).toBe(false);
|
||||
expect(isCoarsePointer()).toBe(false);
|
||||
expect(isSaveData()).toBe(false);
|
||||
expect(isMobileViewport()).toBe(false);
|
||||
});
|
||||
|
||||
it("reads the phone breakpoint from the shared query", () => {
|
||||
mockMedia([MOBILE_QUERY]);
|
||||
expect(isMobileViewport()).toBe(true);
|
||||
expect(MOBILE_QUERY).toBe("(max-width: 767px)");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a touch pointer", () => mockMedia(["(pointer: coarse)"])],
|
||||
["reduced motion", () => mockMedia(["(prefers-reduced-motion: reduce)"])],
|
||||
["the data saver", () => mockNavigator({ connection: { saveData: true } })],
|
||||
["four cores", () => mockNavigator({ hardwareConcurrency: 4 })],
|
||||
["two cores", () => mockNavigator({ hardwareConcurrency: 2 })],
|
||||
["4 GB of memory", () => mockNavigator({ deviceMemory: 4 })],
|
||||
["2 GB of memory", () => mockNavigator({ deviceMemory: 2 })],
|
||||
])("is low power with %s", (_label, arrange) => {
|
||||
arrange();
|
||||
expect(isLowPowerDevice()).toBe(true);
|
||||
});
|
||||
|
||||
it("does not penalize a browser that hides the hardware hints", () => {
|
||||
mockNavigator({ hardwareConcurrency: undefined, deviceMemory: undefined });
|
||||
expect(isLowPowerDevice()).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps 8 GB and 6 cores on the full path", () => {
|
||||
mockNavigator({ hardwareConcurrency: 6, deviceMemory: 8 });
|
||||
expect(isLowPowerDevice()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Leituras do aparelho que decidem o que a página carrega e anima: ponteiro de toque, pouca CPU ou memória,
|
||||
* "economizar dados" e largura de celular. Tudo roda só no navegador (no servidor, devolve o valor mais leve).
|
||||
*/
|
||||
|
||||
/** Abaixo disto (px) a página usa as versões de celular dos vídeos e capas. */
|
||||
export const MOBILE_MAX_WIDTH = 767;
|
||||
export const MOBILE_QUERY = `(max-width: ${MOBILE_MAX_WIDTH}px)`;
|
||||
|
||||
type NavigatorHints = Navigator & {
|
||||
deviceMemory?: number;
|
||||
connection?: { saveData?: boolean };
|
||||
};
|
||||
|
||||
function matches(query: string) {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function" &&
|
||||
window.matchMedia(query).matches
|
||||
);
|
||||
}
|
||||
|
||||
/** Ponteiro principal impreciso (dedo): sem rolagem suavizada nem cena 3D. */
|
||||
export const isCoarsePointer = () => matches("(pointer: coarse)");
|
||||
|
||||
export const prefersReducedMotion = () =>
|
||||
matches("(prefers-reduced-motion: reduce)");
|
||||
|
||||
/** Tela de celular: abaixo de 768 px. */
|
||||
export const isMobileViewport = () => matches(MOBILE_QUERY);
|
||||
|
||||
/** "Economizar dados" ligado: só imagens, nenhum vídeo é baixado sozinho. */
|
||||
export function isSaveData() {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
return (navigator as NavigatorHints).connection?.saveData === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aparelho que não deve montar WebGL: toque, 4 núcleos ou menos, 4 GB de memória ou menos, economia de dados
|
||||
* ou movimento reduzido. O que não é informado pelo navegador (Safari, Firefox) não conta contra o aparelho.
|
||||
*/
|
||||
export function isLowPowerDevice() {
|
||||
if (typeof navigator === "undefined") return true;
|
||||
const hints = navigator as NavigatorHints;
|
||||
return (
|
||||
isCoarsePointer() ||
|
||||
prefersReducedMotion() ||
|
||||
isSaveData() ||
|
||||
(typeof hints.hardwareConcurrency === "number" &&
|
||||
hints.hardwareConcurrency <= 4) ||
|
||||
(typeof hints.deviceMemory === "number" && hints.deviceMemory <= 4)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
chapterAnchors,
|
||||
chapterFraction,
|
||||
chapterIndexAt,
|
||||
desktopFractionAt,
|
||||
getProjectVideo,
|
||||
getShowcaseAssets,
|
||||
mobileFractionAt,
|
||||
SHOWCASE,
|
||||
type ShowcaseConfig,
|
||||
} from "./project-showcase";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de lib/project-showcase.ts (complemento de project-showcase.test.ts):
|
||||
* getProjectVideo (id da vitrine x id fora dela x chaves herdadas de Object, nos dois aparelhos) e os trechos
|
||||
* de chapterAnchors/interpolate/chapterFraction que o arquivo principal não exercita.
|
||||
*/
|
||||
|
||||
function config(
|
||||
chapters: ShowcaseConfig["chapters"],
|
||||
durations = { desktop: 10, mobile: 20 },
|
||||
): ShowcaseConfig {
|
||||
return { id: "fixture", accent: "#fff", durations, weight: 1, chapters };
|
||||
}
|
||||
|
||||
describe("getProjectVideo", () => {
|
||||
it.each(SHOWCASE.map(({ id }) => id))(
|
||||
"path: showcase project %s -> assets for the requested device",
|
||||
(id) => {
|
||||
expect(getProjectVideo(id, "desktop")).toEqual(
|
||||
getShowcaseAssets(id, "desktop"),
|
||||
);
|
||||
expect(getProjectVideo(id, "mobile")).toEqual(
|
||||
getShowcaseAssets(id, "mobile"),
|
||||
);
|
||||
expect(getProjectVideo(id)).toEqual(getShowcaseAssets(id, "desktop"));
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"",
|
||||
"nao-existe",
|
||||
"constructor",
|
||||
"__proto__",
|
||||
"toString",
|
||||
"SEV-MODAINTIMA",
|
||||
])(
|
||||
"path: id %j is not a showcase project -> undefined for both devices",
|
||||
(id) => {
|
||||
expect(getProjectVideo(id)).toBeUndefined();
|
||||
expect(getProjectVideo(id, "mobile")).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("path: the two devices differ in size and in poster, not in the project folder", () => {
|
||||
const desktop = getProjectVideo("nutriscan", "desktop");
|
||||
const mobile = getProjectVideo("nutriscan", "mobile");
|
||||
|
||||
expect(desktop).toMatchObject({ width: 1440, height: 900 });
|
||||
expect(mobile).toMatchObject({ width: 390, height: 844 });
|
||||
expect(desktop?.poster).toBe("/videos/nutriscan/desktop.webp");
|
||||
expect(mobile?.poster).toBe("/videos/nutriscan/mobile.webp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("chapterAnchors", () => {
|
||||
it("path: chapters that advance on both videos -> one anchor each between [0,0] and [1,1]", () => {
|
||||
const anchors = chapterAnchors(
|
||||
config([
|
||||
{ label: "A", desktop: 2, mobile: 4 },
|
||||
{ label: "B", desktop: 5, mobile: 10 },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(anchors).toEqual([
|
||||
[0, 0],
|
||||
[0.2, 0.2],
|
||||
[0.5, 0.5],
|
||||
[1, 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it("path: a chapter at second 0 on both videos -> no duplicate of the origin", () => {
|
||||
const anchors = chapterAnchors(
|
||||
config([
|
||||
{ label: "Abertura", desktop: 0, mobile: 0 },
|
||||
{ label: "B", desktop: 5, mobile: 10 },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(anchors).toEqual([
|
||||
[0, 0],
|
||||
[0.5, 0.5],
|
||||
[1, 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it("path: a chapter that does not advance on the desktop video -> skipped", () => {
|
||||
const anchors = chapterAnchors(
|
||||
config([
|
||||
{ label: "A", desktop: 4, mobile: 4 },
|
||||
{ label: "B", desktop: 4, mobile: 12 },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(anchors).toEqual([
|
||||
[0, 0],
|
||||
[0.4, 0.2],
|
||||
[1, 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it("path: a chapter that does not advance on the mobile video -> skipped", () => {
|
||||
const anchors = chapterAnchors(
|
||||
config([
|
||||
{ label: "A", desktop: 4, mobile: 10 },
|
||||
{ label: "B", desktop: 8, mobile: 10 },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(anchors).toEqual([
|
||||
[0, 0],
|
||||
[0.4, 0.5],
|
||||
[1, 1],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mobileFractionAt / desktopFractionAt (interpolation)", () => {
|
||||
const sample = config([
|
||||
{ label: "A", desktop: 2, mobile: 10 },
|
||||
{ label: "B", desktop: 8, mobile: 15 },
|
||||
]);
|
||||
|
||||
it.each([
|
||||
[-1, 0],
|
||||
[0, 0],
|
||||
[0.2, 0.5],
|
||||
[0.5, 0.5 + (0.3 / 0.6) * 0.25],
|
||||
[0.8, 0.75],
|
||||
[1, 1],
|
||||
[2, 1],
|
||||
])(
|
||||
"path: desktop fraction %f -> mobile fraction %f",
|
||||
(fraction, expected) => {
|
||||
expect(mobileFractionAt(sample, fraction)).toBeCloseTo(expected, 9);
|
||||
},
|
||||
);
|
||||
|
||||
it("path: desktopFractionAt is the inverse on the anchors", () => {
|
||||
expect(desktopFractionAt(sample, 0.5)).toBeCloseTo(0.2, 9);
|
||||
expect(desktopFractionAt(sample, 0.75)).toBeCloseTo(0.8, 9);
|
||||
expect(desktopFractionAt(sample, 0)).toBe(0);
|
||||
expect(desktopFractionAt(sample, 1)).toBe(1);
|
||||
});
|
||||
|
||||
it("path: a chapter right at the end makes a zero-length span -> no division by zero", () => {
|
||||
const flat = config([{ label: "Fim", desktop: 10, mobile: 20 }]);
|
||||
|
||||
expect(mobileFractionAt(flat, 1)).toBe(1);
|
||||
expect(Number.isFinite(mobileFractionAt(flat, 0.999))).toBe(true);
|
||||
expect(Number.isFinite(desktopFractionAt(flat, 1))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chapterIndexAt / chapterFraction", () => {
|
||||
const sample = config([
|
||||
{ label: "A", desktop: 0, mobile: 0 },
|
||||
{ label: "B", desktop: 4, mobile: 8 },
|
||||
{ label: "C", desktop: 8, mobile: 16 },
|
||||
]);
|
||||
|
||||
it.each([
|
||||
[-5, 0],
|
||||
[0, 0],
|
||||
[0.399, 0],
|
||||
[0.4, 1],
|
||||
[0.7999, 1],
|
||||
[0.8, 2],
|
||||
[1, 2],
|
||||
[9, 2],
|
||||
])("path: fraction %f -> chapter %i", (fraction, expected) => {
|
||||
expect(chapterIndexAt(sample, fraction)).toBe(expected);
|
||||
});
|
||||
|
||||
it("path: chapter index in range -> start fraction on each video", () => {
|
||||
expect(chapterFraction(sample, 1, "desktop")).toBeCloseTo(0.4, 9);
|
||||
expect(chapterFraction(sample, 1, "mobile")).toBeCloseTo(0.4, 9);
|
||||
expect(chapterFraction(sample, 2, "desktop")).toBeCloseTo(0.8, 9);
|
||||
});
|
||||
|
||||
it.each([-1, 3, 99])(
|
||||
"path: chapter index %i does not exist -> 0",
|
||||
(index) => {
|
||||
expect(chapterFraction(sample, index, "desktop")).toBe(0);
|
||||
expect(chapterFraction(sample, index, "mobile")).toBe(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getProjectVideo,
|
||||
getScrubSource,
|
||||
getShowcaseAssets,
|
||||
MAX_CHAPTERS,
|
||||
mobileFractionAt,
|
||||
PROJECT_VIDEO_IDS,
|
||||
SHOWCASE,
|
||||
@@ -16,6 +17,34 @@ import {
|
||||
|
||||
const MAX_BYTES = 3 * 1024 * 1024;
|
||||
const publicFile = (path: string) => join(process.cwd(), "public", path);
|
||||
/** Os projetos de cliente que a home entrega à vitrine. */
|
||||
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);
|
||||
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", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it("keeps the NutriScan configuration for /cases", () => {
|
||||
const nutriscan = SHOWCASE.find((config) => config.id === "nutriscan");
|
||||
expect(nutriscan).toBeDefined();
|
||||
expect(PROJECT_VIDEO_IDS).toContain("nutriscan");
|
||||
expect(getOgImagePath("nutriscan")).toBe("/videos/nutriscan/og.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("showcase chapters", () => {
|
||||
it.each(SHOWCASE)("$id has ordered chapters inside both videos", (config) => {
|
||||
@@ -60,8 +89,9 @@ describe("showcase chapters", () => {
|
||||
expect(lavacao).toBeDefined();
|
||||
if (!lavacao) return;
|
||||
expect(chapterIndexAt(lavacao, 0)).toBe(0);
|
||||
expect(chapterIndexAt(lavacao, 3 / lavacao.durations.desktop)).toBe(1);
|
||||
expect(chapterIndexAt(lavacao, 10 / lavacao.durations.desktop)).toBe(2);
|
||||
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, 1)).toBe(lavacao.chapters.length - 1);
|
||||
expect(chapterIndexAt(lavacao, 2)).toBe(lavacao.chapters.length - 1);
|
||||
expect(chapterIndexAt(lavacao, -1)).toBe(0);
|
||||
@@ -71,6 +101,22 @@ describe("showcase chapters", () => {
|
||||
describe("showcase assets", () => {
|
||||
it("returns undefined for projects without a recorded video", () => {
|
||||
expect(getProjectVideo("reddit-explorer")).toBeUndefined();
|
||||
expect(getProjectVideo("reddit-explorer", "mobile")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("picks the version for the device, desktop by default", () => {
|
||||
expect(getProjectVideo("nutriscan")?.poster).toBe(
|
||||
"/videos/nutriscan/desktop.webp",
|
||||
);
|
||||
expect(getProjectVideo("nutriscan", "desktop")).toEqual(
|
||||
getShowcaseAssets("nutriscan", "desktop"),
|
||||
);
|
||||
const mobile = getProjectVideo("nutriscan", "mobile");
|
||||
expect(mobile?.poster).toBe("/videos/nutriscan/mobile.webp");
|
||||
expect(mobile?.sources.map((source) => source.src)).toEqual([
|
||||
"/videos/nutriscan/mobile.webm",
|
||||
"/videos/nutriscan/mobile.mp4",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(PROJECT_VIDEO_IDS)(
|
||||
|
||||
+21
-16
@@ -2,6 +2,7 @@
|
||||
* Vitrine dos projetos: tudo o que os cards, a vitrine animada e a janela ampliada sabem sobre os vídeos.
|
||||
*
|
||||
* Arquivos em public/videos/<id>/ (gravados com scripts/record-project-videos.mjs):
|
||||
* - desktop.webp e mobile.webp: pôsteres; a versão de celular vale abaixo de 768 px (lib/device.ts);
|
||||
* - desktop.{webm,mp4,webp} e mobile.{webm,mp4,webp}: reprodução normal, até 3 MB cada vídeo;
|
||||
* - scrub-desktop.mp4 e scrub-mobile.mp4: mesmos vídeos com um quadro-chave a cada 6 quadros, para a imagem
|
||||
* acompanhar a rolagem sem travar quando o tempo é mudado a cada quadro;
|
||||
@@ -27,18 +28,24 @@ export interface ShowcaseConfig {
|
||||
chapters: readonly ShowcaseChapter[];
|
||||
}
|
||||
|
||||
/** Ordem da vitrine = ordem dos cards na home. Capítulos marcados nos quadros dos próprios vídeos. */
|
||||
/** 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;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const SHOWCASE: readonly ShowcaseConfig[] = [
|
||||
{
|
||||
id: "sev-modaintima",
|
||||
accent: "#c2303a",
|
||||
durations: { desktop: 17.9, mobile: 30.1 },
|
||||
weight: 1,
|
||||
weight: 0.95,
|
||||
chapters: [
|
||||
{ label: "Abertura", desktop: 0, mobile: 0 },
|
||||
{ label: "Categorias", desktop: 2.7, mobile: 1.9 },
|
||||
{ label: "As favoritas", desktop: 7.2, mobile: 5.6 },
|
||||
{ label: "Nossa essência", desktop: 10.7, mobile: 8.6 },
|
||||
{ label: "Coleção completa", desktop: 13.4, mobile: 11 },
|
||||
],
|
||||
},
|
||||
@@ -46,24 +53,21 @@ export const SHOWCASE: readonly ShowcaseConfig[] = [
|
||||
id: "lavacao-047",
|
||||
accent: "#4aa3ff",
|
||||
durations: { desktop: 30.07, mobile: 30.1 },
|
||||
weight: 1.4,
|
||||
weight: 1.1,
|
||||
chapters: [
|
||||
{ label: "Abertura", desktop: 0, mobile: 0 },
|
||||
{ label: "Tabela de preços", desktop: 1.5, mobile: 1.9 },
|
||||
{ label: "Do sujo ao brilho", desktop: 5.5, mobile: 5.6 },
|
||||
{ label: "Como funciona", desktop: 13.5, mobile: 13.2 },
|
||||
{ label: "Galeria e mapa", desktop: 18.5, mobile: 17.5 },
|
||||
{ label: "Perguntas e contato", desktop: 24, mobile: 22 },
|
||||
],
|
||||
},
|
||||
{
|
||||
// fora da home atual, mas usado em /cases (og-card): a configuração fica
|
||||
id: "nutriscan",
|
||||
accent: "#2f9e5f",
|
||||
durations: { desktop: 15.67, mobile: 26.4 },
|
||||
weight: 0.9,
|
||||
weight: 0.8,
|
||||
chapters: [
|
||||
{ label: "Busca", desktop: 0, mobile: 0 },
|
||||
{ label: "Por que confiar", desktop: 2.3, mobile: 3 },
|
||||
{ label: "Quatro etapas", desktop: 7, mobile: 8 },
|
||||
{ label: "Apoio ao projeto", desktop: 10.9, mobile: 16.3 },
|
||||
],
|
||||
@@ -72,12 +76,10 @@ export const SHOWCASE: readonly ShowcaseConfig[] = [
|
||||
id: "sc-plus",
|
||||
accent: "#22c55e",
|
||||
durations: { desktop: 13, mobile: 19.67 },
|
||||
weight: 0.85,
|
||||
weight: 0.8,
|
||||
chapters: [
|
||||
{ label: "Abertura", desktop: 0, mobile: 0 },
|
||||
{ label: "Sobre", desktop: 1.5, mobile: 2.2 },
|
||||
{ label: "Áreas e projetos", desktop: 3.8, mobile: 4.6 },
|
||||
{ label: "Apoiadores e participação", desktop: 7.5, mobile: 12.8 },
|
||||
{ label: "Contato", desktop: 10.4, mobile: 16.9 },
|
||||
],
|
||||
},
|
||||
@@ -132,9 +134,12 @@ export function getOgImagePath(id: string) {
|
||||
return `/videos/${id}/og.jpg`;
|
||||
}
|
||||
|
||||
/** Vídeo desktop de um projeto da vitrine: é o que as capas dos cards tocam. */
|
||||
export function getProjectVideo(id: string): ShowcaseAssets | undefined {
|
||||
return byId.has(id) ? getShowcaseAssets(id, "desktop") : undefined;
|
||||
/** Arquivos de um projeto da vitrine para o aparelho (desktop por padrão): é o que as capas dos cards usam. */
|
||||
export function getProjectVideo(
|
||||
id: string,
|
||||
device: Device = "desktop",
|
||||
): ShowcaseAssets | undefined {
|
||||
return byId.has(id) ? getShowcaseAssets(id, device) : undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user