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:
Evandro Fonseca Junior committed 2026-10-14 03:32:23 -03:00
1 parent 8d07b8c6bb
commit c9a0b80656
48 files changed
+7210 -1241

No files matched your search

+185
View File
@@ -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);
});
});
+74
View File
@@ -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);
});
});
+53
View File
@@ -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)
);
}
+199
View File
@@ -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);
},
);
});
+48 -2
View File
@@ -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
View File
@@ -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;
}
// ---------------------------------------------------------------------------------------------------------