Files
portifolio-evandrodevbr/lib/project-showcase.ts
T
Evandro Fonseca Junior c9a0b80656 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.
2026-10-14 03:32:23 -03:00

217 lines
7.3 KiB
TypeScript

/**
* 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;
* - og.jpg: capa 1200x630 das imagens de compartilhamento.
*/
export interface ShowcaseChapter {
label: string;
/** Segundo do vídeo desktop em que o trecho começa. */
desktop: number;
/** Segundo do vídeo mobile em que o mesmo trecho começa (as páginas têm alturas diferentes). */
mobile: number;
}
export interface ShowcaseConfig {
id: string;
/** Cor de destaque do site, usada só no brilho ao redor do vídeo. */
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. */
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;
/**
* 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: 0.95,
chapters: [
{ label: "Abertura", desktop: 0, mobile: 0 },
{ label: "As favoritas", desktop: 7.2, mobile: 5.6 },
{ label: "Coleção completa", desktop: 13.4, mobile: 11 },
],
},
{
id: "lavacao-047",
accent: "#4aa3ff",
durations: { desktop: 30.07, mobile: 30.1 },
weight: 1.1,
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 },
],
},
{
// 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.8,
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: 13, mobile: 19.67 },
weight: 0.8,
chapters: [
{ label: "Abertura", desktop: 0, mobile: 0 },
{ label: "Áreas e projetos", desktop: 3.8, mobile: 4.6 },
{ label: "Contato", desktop: 10.4, mobile: 16.9 },
],
},
{
id: "as-servicos",
accent: "#2563eb",
durations: { desktop: 5.07, mobile: 7.7 },
weight: 0.6,
chapters: [
{ label: "Três áreas", desktop: 0, mobile: 0 },
{ label: "Critérios de entrega", desktop: 1.5, mobile: 3 },
{ label: "Contato direto", desktop: 2.4, mobile: 4.4 },
],
},
];
export const PROJECT_VIDEO_IDS = SHOWCASE.map((config) => config.id);
const byId = new Map(SHOWCASE.map((config) => [config.id, config]));
export function getShowcaseConfig(id: string): ShowcaseConfig | undefined {
return byId.get(id);
}
export type Device = "desktop" | "mobile";
export interface ShowcaseAssets {
poster: string;
width: number;
height: number;
sources: Array<{ src: string; type: string }>;
}
export function getShowcaseAssets(id: string, device: Device) {
const base = `/videos/${id}/${device}`;
return {
poster: `${base}.webp`,
width: device === "desktop" ? 1440 : 390,
height: device === "desktop" ? 900 : 844,
sources: [
{ src: `${base}.webm`, type: 'video/webm; codecs="vp9"' },
{ src: `${base}.mp4`, type: "video/mp4" },
],
} satisfies ShowcaseAssets;
}
export function getScrubSource(id: string, device: Device) {
return `/videos/${id}/scrub-${device}.mp4`;
}
export function getOgImagePath(id: string) {
return `/videos/${id}/og.jpg`;
}
/** 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;
}
// ---------------------------------------------------------------------------------------------------------
// Capítulos e sincronia entre as duas telas
// ---------------------------------------------------------------------------------------------------------
/** Pontos [fração do desktop, fração do mobile] que dizem onde cada capítulo começa em cada vídeo. */
export function chapterAnchors(
config: ShowcaseConfig,
): Array<[number, number]> {
const { desktop, mobile } = config.durations;
const points: Array<[number, number]> = [[0, 0]];
for (const chapter of config.chapters) {
const point: [number, number] = [
chapter.desktop / desktop,
chapter.mobile / mobile,
];
const last = points[points.length - 1];
if (point[0] > last[0] && point[1] > last[1]) points.push(point);
}
points.push([1, 1]);
return points;
}
function interpolate(
points: Array<[number, number]>,
value: number,
from: 0 | 1,
): number {
const to = from === 0 ? 1 : 0;
const x = Math.min(1, Math.max(0, value));
for (let i = 1; i < points.length; i += 1) {
const [a, b] = [points[i - 1], points[i]];
if (x <= b[from]) {
const span = b[from] - a[from];
const ratio = span === 0 ? 0 : (x - a[from]) / span;
return a[to] + ratio * (b[to] - a[to]);
}
}
return 1;
}
/** Fração do vídeo mobile que mostra o mesmo ponto do site que a fração `fraction` do vídeo desktop. */
export function mobileFractionAt(config: ShowcaseConfig, fraction: number) {
return interpolate(chapterAnchors(config), fraction, 0);
}
/** O contrário: fração do desktop para uma fração do vídeo mobile. */
export function desktopFractionAt(config: ShowcaseConfig, fraction: number) {
return interpolate(chapterAnchors(config), fraction, 1);
}
/** 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;
let index = 0;
config.chapters.forEach((chapter, i) => {
if (seconds >= chapter.desktop - 1e-6) index = i;
});
return index;
}
/** Fração (0 a 1) do vídeo `device` em que o capítulo `index` começa. */
export function chapterFraction(
config: ShowcaseConfig,
index: number,
device: Device,
) {
const chapter = config.chapters[index];
if (!chapter) return 0;
return device === "desktop"
? chapter.desktop / config.durations.desktop
: chapter.mobile / config.durations.mobile;
}